Surn commited on
Commit
e7dfdf9
·
1 Parent(s): 1345b51

Hall of Legends Pre set up

Browse files
.github/copilot-instructions.md CHANGED
@@ -1,17 +1,18 @@
1
  # Copilot Instructions
2
- ## General Guidelines
3
- minimal changes to existing code
4
- preserve functionality when possible
5
- clear and concise comments
6
- no plan unless specified
7
- no compile unless specified
8
- no test unless specified
9
- if testing is specified:
10
- MSTest framework
11
- UV is used
12
- avoid New Dependencies
13
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  ## Project-Specific Rules
16
  - Remove AI generation of wordlists and audio system from the basic codebase
17
- - Keep challenge mode/HF storage and PWA support in the basic codebase
 
 
1
  # Copilot Instructions
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ ## General Guidelines
4
+ - Minimal changes to existing code
5
+ - Preserve functionality when possible
6
+ - Clear and concise comments
7
+ - No plan unless specified
8
+ - No compile unless specified
9
+ - No test unless specified
10
+ - If testing is specified:
11
+ - MSTest framework
12
+ - UV is used
13
+ - Avoid new dependencies
14
 
15
  ## Project-Specific Rules
16
  - Remove AI generation of wordlists and audio system from the basic codebase
17
+ - Keep challenge mode/HF storage and PWA support in the basic codebase
18
+ - Implement Hall functionality using existing HF-backed storage patterns (no new local data file) and route it via `?page=hall` instead of leaderboard naming
README.md CHANGED
@@ -61,8 +61,13 @@ BattleWords is a vocabulary learning game inspired by classic Battleship mechani
61
  - 10 incorrect guess limit per game
62
  - Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
63
 
 
 
 
 
 
64
  ### Visuals
65
- - Ocean-themed gradient background with wave animations
66
  - Responsive UI built with Streamlit
67
 
68
  ### Word lists
@@ -130,7 +135,43 @@ docker run -p8501:8501 battlewords
130
  ```
131
 
132
  ### Environment Variables
133
- None required.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  ## Folder Structure
136
 
 
61
  - 10 incorrect guess limit per game
62
  - Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
63
 
64
+ ### Hall of Legends
65
+ - Legendary runs (`score >= 46`) can be saved to Hall of Legends from the game-over flow.
66
+ - Hall page is available in development and production at `?page=hall`.
67
+ - Hall JSON is compatible with Wrdler leaderboard schema (`challenge_id`, `entry_type`, `users`, etc.) with `entry_type` set to `hall`.
68
+
69
  ### Visuals
70
+ - Ocean-themed gradient background with wave animations are removed
71
  - Responsive UI built with Streamlit
72
 
73
  ### Word lists
 
135
  ```
136
 
137
  ### Environment Variables
138
+ - `HF_API_TOKEN` (required for HF-backed Hall save/load in deployed environments)
139
+ - `HF_REPO_ID` (HF dataset/repo used by storage helpers)
140
+ - `SHORTENER_JSON_FILE` (optional, default: `shortener.json`)
141
+ - `HALL_OF_LEGENDS_FILE` (optional, Hall JSON path in HF repo)
142
+
143
+ ### Hall of Legends JSON Example (compatible)
144
+
145
+ ```json
146
+ {
147
+ "challenge_id": "hall-of-legends/classic-classic-12x12",
148
+ "entry_type": "hall",
149
+ "game_mode": "classic",
150
+ "grid_size": 12,
151
+ "puzzle_options": {
152
+ "spacer": 1,
153
+ "may_overlap": false
154
+ },
155
+ "users": [
156
+ {
157
+ "uid": "20260325T184512Z-A9K2QW",
158
+ "username": "Charles",
159
+ "word_list": ["BLADE", "SHORE", "MARKET", "TUNNEL", "RIBBON", "CANDLE"],
160
+ "score": 49,
161
+ "time": 132.447291,
162
+ "timestamp": "2026-03-25T18:45:12.902145+00:00",
163
+ "word_list_difficulty": 112.3849012231
164
+ }
165
+ ],
166
+ "created_at": "2026-03-25T18:45:13.111942+00:00",
167
+ "version": "0.2.40",
168
+ "show_incorrect_guesses": true,
169
+ "enable_free_letters": false,
170
+ "wordlist_source": "classic.txt",
171
+ "game_title": "Battlewords",
172
+ "max_display_entries": 30
173
+ }
174
+ ```
175
 
176
  ## Folder Structure
177
 
battlewords/modules/constants.py CHANGED
@@ -68,6 +68,16 @@ def load_settings() -> Dict[str, Any]:
68
  # Load settings at module level for easy import
69
  APP_SETTINGS = load_settings()
70
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  # ---------------------------------------------------------------------------
73
  # Temporary Directory Configuration
 
68
  # Load settings at module level for easy import
69
  APP_SETTINGS = load_settings()
70
 
71
+ # ---------------------------------------------------------------------------
72
+ # Hugging Face Storage Configuration
73
+ # ---------------------------------------------------------------------------
74
+
75
+ HF_API_TOKEN = os.getenv("HF_API_TOKEN", "")
76
+ HF_REPO_ID = os.getenv("HF_REPO_ID", "")
77
+ SHORTENER_JSON_FILE = os.getenv("SHORTENER_JSON_FILE", "shortener.json")
78
+ SPACE_NAME = os.getenv("SPACE_NAME", "Surn/BattleWords")
79
+ HALL_OF_LEGENDS_FILE = os.getenv("HALL_OF_LEGENDS_FILE", "games/hall_of_legends.json")
80
+
81
 
82
  # ---------------------------------------------------------------------------
83
  # Temporary Directory Configuration
battlewords/modules/storage.md ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ - **📁 Listing folders and files in HuggingFace repositories.**
10
+ - **🔑 Cryptographic key management for Open Badge 3.0 issuers.**
11
+
12
+ ## Key Functions
13
+
14
+ ### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
15
+ - **Purpose:**
16
+ 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.
17
+ - **Usage Example:**from modules.storage import generate_permalink
18
+
19
+ valid_files = [
20
+ "models/3d_model.glb",
21
+ "images/model_texture.png",
22
+ "images/model_depth.png"
23
+ ]
24
+ base_url_external = "https://huggingface.co/datasets/Surn/Storage/resolve/main/saved_models/my_model"
25
+ permalink = generate_permalink(valid_files, base_url_external)
26
+ if permalink:
27
+ print("Permalink:", permalink)
28
+ ### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
29
+ - **Purpose:**
30
+ 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.
31
+ - **Usage Example:**from modules.storage import generate_permalink_from_urls
32
+
33
+ model_url = "https://example.com/model.glb"
34
+ hm_url = "https://example.com/heightmap.png"
35
+ img_url = "https://example.com/source.png"
36
+
37
+ permalink = generate_permalink_from_urls(model_url, hm_url, img_url)
38
+ print("Generated Permalink:", permalink)
39
+ ### 3. `upload_files_to_repo(files, repo_id, folder_name, create_permalink=False, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space")`
40
+ - **Purpose:**
41
+ 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.
42
+ The function's return type is `Union[Dict[str, Any], List[Tuple[Any, str]]]`.
43
+ - When `create_permalink` is `True` and exactly three valid files (one model and two images) are provided, the function returns a dictionary:{
44
+ "response": <upload_folder_response>,
45
+ "permalink": "<full_permalink_url>",
46
+ "short_permalink": "<shortened_permalink_url_with_sid>"
47
+ } - 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)`.
48
+ - 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).
49
+ - **Usage Example:**
50
+
51
+ **a. Uploading with permalink creation:**from modules.storage import upload_files_to_repo
52
+
53
+ files_for_permalink = [
54
+ "local/path/to/model.glb",
55
+ "local/path/to/heightmap.png",
56
+ "local/path/to/image.png"
57
+ ]
58
+ repo_id = "Surn/Storage" # Make sure this is defined, e.g., from constants or environment variables
59
+ folder_name = "my_new_model_with_permalink"
60
+
61
+ upload_result = upload_files_to_repo(
62
+ files_for_permalink,
63
+ repo_id,
64
+ folder_name,
65
+ create_permalink=True
66
+ )
67
+
68
+ if isinstance(upload_result, dict):
69
+ print("Upload Response:", upload_result.get("response"))
70
+ print("Full Permalink:", upload_result.get("permalink"))
71
+ print("Short Permalink:", upload_result.get("short_permalink"))
72
+ elif upload_result: # Check if list is not empty
73
+ print("Upload Response for individual files:")
74
+ for res, link in upload_result:
75
+ print(f" Response: {res}, Link: {link}")
76
+ else:
77
+ print("No files uploaded or error occurred.")
78
+ **b. Uploading without permalink creation (or if conditions for permalink are not met):**from modules.storage import upload_files_to_repo
79
+
80
+ files_individual = [
81
+ "local/path/to/another_model.obj",
82
+ "local/path/to/texture.jpg"
83
+ ]
84
+ repo_id = "Surn/Storage"
85
+ folder_name = "my_other_uploads"
86
+
87
+ upload_results_list = upload_files_to_repo(
88
+ files_individual,
89
+ repo_id,
90
+ folder_name,
91
+ create_permalink=False # Or if create_permalink=True but not 1 model & 2 images
92
+ )
93
+
94
+ if upload_results_list: # Will be a list of tuples
95
+ print("Upload results for individual files:")
96
+ for res, link in upload_results_list:
97
+ print(f" Upload Response: {res}, File Link: {link}")
98
+ else:
99
+ print("No files uploaded or error occurred.")
100
+ ### 4. URL Shortening Functions: `gen_full_url(...)` and Helpers
101
+ 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:
102
+ - **Read:** Look up the full URL using a provided short URL ID.
103
+ - **Create:** Generate a new short URL ID for a full URL if no existing mapping exists.
104
+ - **Update/Conflict Handling:**
105
+ If both short URL ID and full URL are provided, it checks consistency and either confirms or reports a conflict.
106
+
107
+ #### `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")`
108
+ - **Purpose:**
109
+ Based on which parameter is provided, it retrieves or creates a mapping between a short URL ID and a full URL.
110
+ - If only `short_url` (the ID) is given, it returns the corresponding `full_url`.
111
+ - If only `full_url` is given, it looks up an existing `short_url` ID or generates and stores a new one.
112
+ - If both are given, it validates and returns the mapping or an error status.
113
+ - **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).
114
+ - **Usage Examples:**
115
+
116
+ **a. Convert a full URL into a short URL ID:**from modules.storage import gen_full_url
117
+ from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
118
+
119
+ 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"
120
+
121
+ status, short_id = gen_full_url(
122
+ full_url=full_permalink,
123
+ repo_id=HF_REPO_ID,
124
+ json_file=SHORTENER_JSON_FILE
125
+ )
126
+ print("Status:", status)
127
+ if status == "created_short" or status == "success_retrieved_short":
128
+ print("Shortened URL ID:", short_id)
129
+ # Construct the full short URL for sharing:
130
+ # permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
131
+ # shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
132
+ # print("Shareable Short URL:", shareable_short_url)
133
+ **b. Retrieve the full URL from a short URL ID:**from modules.storage import gen_full_url
134
+ from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
135
+
136
+ short_id_to_lookup = "aBcDeFg1" # Example short URL ID
137
+
138
+ status, retrieved_full_url = gen_full_url(
139
+ short_url=short_id_to_lookup,
140
+ repo_id=HF_REPO_ID,
141
+ json_file=SHORTENER_JSON_FILE
142
+ )
143
+ print("Status:", status)
144
+ if status == "success_retrieved_full":
145
+ print("Retrieved Full URL:", retrieved_full_url)
146
+ ## 📁 Repository Folder Listing Functions
147
+
148
+ ### 5. `_list_repo_folders(repo_id, path_prefix, repo_type="dataset")`
149
+ - **Purpose:**
150
+ List folder names under a given path in a HuggingFace repository. Enables folder-based discovery without index files.
151
+ - **Parameters:**
152
+ - `repo_id` (str): The repository ID on Hugging Face
153
+ - `path_prefix` (str): The path prefix to list folders under
154
+ - `repo_type` (str): Repository type. Default is `"dataset"`.
155
+ - **Returns:** `List[str]` - List of folder names found under the path_prefix.
156
+ - **Usage Example:**
157
+ ```python
158
+ from modules.storage import _list_repo_folders
159
+
160
+ # List all date folders in daily leaderboards
161
+ folders = _list_repo_folders("Surn/Wrdler-Data", "games/leaderboards/daily")
162
+ print("Available dates:", folders)
163
+ # Output: ['2025-01-27', '2025-01-26', '2025-01-25']
164
+ ```
165
+
166
+ ### 6. `_list_repo_files_in_folder(repo_id, folder_path, repo_type="dataset")`
167
+ - **Purpose:**
168
+ List file names directly under a folder in a HuggingFace repository.
169
+ - **Parameters:**
170
+ - `repo_id` (str): The repository ID on Hugging Face
171
+ - `folder_path` (str): The folder path to list files under
172
+ - `repo_type` (str): Repository type. Default is `"dataset"`.
173
+ - **Returns:** `List[str]` - List of file names found directly in the folder.
174
+ - **Usage Example:**
175
+ ```python
176
+ from modules.storage import _list_repo_files_in_folder
177
+
178
+ files = _list_repo_files_in_folder(
179
+ "Surn/Wrdler-Data",
180
+ "games/leaderboards/daily/2025-01-27/classic-classic-0"
181
+ )
182
+ print("Files:", files)
183
+ # Output: ['settings.json']
184
+ ```
185
+
186
+ ## 🔑 Cryptographic Key Management Functions
187
+
188
+ ### 7. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
189
+ - **Purpose:**
190
+ Securely store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
191
+ - **⚠️ IMPORTANT:** This function requires a PRIVATE Hugging Face repository to ensure the security of stored private keys. Never use this with public repositories.
192
+ - **Storage Structure:**keys/issuers/{issuer_id}/
193
+ ├── private_key.json (encrypted)
194
+ └── public_key.json- **Returns:** `bool` - True if keys were stored successfully, False otherwise.
195
+ - **Usage Example:**from modules.storage import store_issuer_keypair
196
+
197
+ # Example Ed25519 keys (multibase encoded)
198
+ issuer_id = "https://example.edu/issuers/565049"
199
+ public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
200
+ private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
201
+
202
+ success = store_issuer_keypair(issuer_id, public_key, private_key)
203
+ if success:
204
+ print("Keys stored successfully")
205
+ else:
206
+ print("Failed to store keys")
207
+ ### 8. `get_issuer_keypair(issuer_id, repo_id=None)`
208
+ - **Purpose:**
209
+ Retrieve and decrypt stored cryptographic keys for an issuer from the private Hugging Face repository.
210
+ - **⚠️ IMPORTANT:** This function accesses a PRIVATE Hugging Face repository containing encrypted private keys. Ensure proper access control and security measures.
211
+ - **Returns:** `Tuple[Optional[str], Optional[str]]` - (public_key, private_key) or (None, None) if not found.
212
+ - **Usage Example:**from modules.storage import get_issuer_keypair
213
+
214
+ issuer_id = "https://example.edu/issuers/565049"
215
+ public_key, private_key = get_issuer_keypair(issuer_id)
216
+
217
+ if public_key and private_key:
218
+ print("Keys retrieved successfully")
219
+ print(f"Public key: {public_key}")
220
+ # Use private_key for signing operations
221
+ else:
222
+ print("Keys not found or error occurred")
223
+ ### 9. `get_verification_methods_registry(repo_id=None)`
224
+ - **Purpose:**
225
+ Retrieve the global verification methods registry containing all registered issuer public keys.
226
+ - **Returns:** `Dict[str, Any]` - Registry data containing all verification methods.
227
+ - **Usage Example:**from modules.storage import get_verification_methods_registry
228
+
229
+ registry = get_verification_methods_registry()
230
+ methods = registry.get("verification_methods", [])
231
+
232
+ for method in methods:
233
+ print(f"Issuer: {method['issuer_id']}")
234
+ print(f"Public Key: {method['public_key']}")
235
+ print(f"Key Type: {method['key_type']}")
236
+ print("---")
237
+ ### 10. `list_issuer_ids(repo_id=None)`
238
+ - **Purpose:**
239
+ List all issuer IDs that have stored keys in the repository.
240
+ - **Returns:** `List[str]` - List of issuer IDs.
241
+ - **Usage Example:**from modules.storage import list_issuer_ids
242
+
243
+ issuer_ids = list_issuer_ids()
244
+ print("Registered issuers:")
245
+ for issuer_id in issuer_ids:
246
+ print(f" - {issuer_id}")
247
+ ## Notes
248
+ - **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.
249
+ - **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.
250
+ - **🔐 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.
251
+ - **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`.
252
+ - **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.
253
+ - **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
254
+ - **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.
255
+ - **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.
256
+
257
+ ## 🔒 Security Considerations for Key Management
258
+
259
+ 1. **Private Repository Only:** Always use private repositories for key storage to protect cryptographic material.
260
+ 2. **Key Sanitization:** Issuer IDs are sanitized for file system compatibility (replacing special characters with underscores).
261
+ 3. **Encryption:** Private keys are encrypted before storage. Upgrade to Fernet encryption in production.
262
+ 4. **Access Control:** Implement proper authentication and authorization for key access.
263
+ 5. **Key Rotation:** Consider implementing key rotation mechanisms for enhanced security.
264
+ 6. **Audit Logging:** Monitor key access and usage patterns for security auditing.
265
+
266
+ ---
267
+
268
+ This guide provides the essential usage examples for interacting with the storage, URL-shortening, folder listing, and cryptographic key management functionality. You can integrate these examples into your application or use them as a reference when extending functionality.
battlewords/modules/storage.py ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # modules/storage.py
2
+ __version__ = "0.1.6"
3
+ import os
4
+ import urllib.parse
5
+ import tempfile
6
+ import shutil
7
+ import json
8
+ import base64
9
+ import logging
10
+ from datetime import datetime, timezone
11
+ from huggingface_hub import login, upload_folder, hf_hub_download, HfApi
12
+ from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
13
+ from .constants import HF_API_TOKEN, upload_file_types, model_extensions, image_extensions, audio_extensions, video_extensions, doc_extensions, HF_REPO_ID, SHORTENER_JSON_FILE
14
+ from typing import Any, Dict, List, Tuple, Union, Optional
15
+
16
+ # Configure professional logging
17
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # see storage.md for detailed information about the storage module and its functions.
21
+
22
+ def generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space"):
23
+ """
24
+ Given a list of valid files, checks if they contain exactly 1 model file and 2 image files.
25
+ Constructs and returns a permalink URL with query parameters if the criteria is met.
26
+ Otherwise, returns None.
27
+ """
28
+ model_link = None
29
+ images_links = []
30
+ audio_links = []
31
+ video_links = []
32
+ doc_links = []
33
+ for f in valid_files:
34
+ filename = os.path.basename(f)
35
+ ext = os.path.splitext(filename)[1].lower()
36
+ if ext in model_extensions:
37
+ if model_link is None:
38
+ model_link = f"{base_url_external}/{filename}"
39
+ elif ext in image_extensions:
40
+ images_links.append(f"{base_url_external}/{filename}")
41
+ elif ext in audio_extensions:
42
+ audio_links.append(f"{base_url_external}/{filename}")
43
+ elif ext in video_extensions:
44
+ video_links.append(f"{base_url_external}/{filename}")
45
+ elif ext in doc_extensions:
46
+ doc_links.append(f"{base_url_external}/{filename}")
47
+ if model_link and len(images_links) == 2:
48
+ # Construct a permalink to the viewer project with query parameters.
49
+ permalink_viewer_url = f"https://{permalink_viewer_url}/"
50
+ params = {"3d": model_link, "hm": images_links[0], "image": images_links[1]}
51
+ query_str = urllib.parse.urlencode(params)
52
+ return f"{permalink_viewer_url}?{query_str}"
53
+ return None
54
+
55
+ def generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space"):
56
+ """
57
+ Constructs and returns a permalink URL with query string parameters for the viewer.
58
+ Each parameter is passed separately so that the image positions remain consistent.
59
+
60
+ Parameters:
61
+ model_url (str): Processed URL for the 3D model.
62
+ hm_url (str): Processed URL for the height map image.
63
+ img_url (str): Processed URL for the main image.
64
+ permalink_viewer_url (str): The base viewer URL.
65
+
66
+ Returns:
67
+ str: The generated permalink URL.
68
+ """
69
+ import urllib.parse
70
+ params = {"3d": model_url, "hm": hm_url, "image": img_url}
71
+ query_str = urllib.parse.urlencode(params)
72
+ return f"https://{permalink_viewer_url}/?{query_str}"
73
+
74
+ def upload_files_to_repo(
75
+ files: List[Any],
76
+ repo_id: str,
77
+ folder_name: str,
78
+ create_permalink: bool = False,
79
+ repo_type: str = "dataset",
80
+ permalink_viewer_url: str = "surn-3d-viewer.hf.space"
81
+ ) -> Union[Dict[str, Any], List[Tuple[Any, str]]]:
82
+ """
83
+ Uploads multiple files to a Hugging Face repository using a batch upload approach via upload_folder.
84
+
85
+ Parameters:
86
+ files (list): A list of file paths (str) to upload.
87
+ repo_id (str): The repository ID on Hugging Face for storage, e.g. "Surn/Storage".
88
+ folder_name (str): The subfolder within the repository where files will be saved.
89
+ create_permalink (bool): If True and if exactly three files are uploaded (1 model and 2 images),
90
+ returns a single permalink to the project with query parameters.
91
+ Otherwise, returns individual permalinks for each file.
92
+ repo_type (str): Repository type ("space", "dataset", etc.). Default is "dataset".
93
+ permalink_viewer_url (str): The base viewer URL.
94
+
95
+ Returns:
96
+ Union[Dict[str, Any], List[Tuple[Any, str]]]:
97
+ If create_permalink is True and files match the criteria:
98
+ dict: {
99
+ "response": <upload response>,
100
+ "permalink": <full_permalink URL>,
101
+ "short_permalink": <shortened permalink URL>
102
+ }
103
+ Otherwise:
104
+ list: A list of tuples (response, permalink) for each file.
105
+ """
106
+ logger.info(f"📤 Starting batch upload to repository: {repo_id}")
107
+ logger.debug(f"📁 Target folder: {folder_name}")
108
+ logger.debug(f"🔗 Create permalink: {create_permalink}")
109
+
110
+ # Log in using the HF API token.
111
+ try:
112
+ login(token=HF_API_TOKEN)
113
+ logger.debug("🔑 Authenticated with Hugging Face")
114
+ except Exception as e:
115
+ logger.error(f"🚫 Authentication failed: {e}")
116
+ return {"response": "Authentication failed", "permalink": None, "short_permalink": None} if create_permalink else []
117
+
118
+ valid_files = []
119
+ permalink_short = None
120
+
121
+ # Ensure folder_name does not have a trailing slash.
122
+ folder_name = folder_name.rstrip("/")
123
+
124
+ # Filter for valid files based on allowed extensions.
125
+ logger.debug("🔍 Filtering valid files...")
126
+ for f in files:
127
+ file_name = f if isinstance(f, str) else f.name if hasattr(f, "name") else None
128
+ if file_name is None:
129
+ continue
130
+ ext = os.path.splitext(file_name)[1].lower()
131
+ if ext in upload_file_types:
132
+ valid_files.append(f)
133
+ logger.debug(f"✅ Valid file: {os.path.basename(file_name)}")
134
+ else:
135
+ logger.debug(f"⚠️ Skipped file with invalid extension: {os.path.basename(file_name)}")
136
+
137
+ logger.info(f"📊 Found {len(valid_files)} valid files out of {len(files)} total")
138
+
139
+ if not valid_files:
140
+ logger.warning("⚠️ No valid files to upload")
141
+ if create_permalink:
142
+ return {
143
+ "response": "No valid files to upload.",
144
+ "permalink": None,
145
+ "short_permalink": None
146
+ }
147
+ return []
148
+
149
+ # Create a temporary directory and copy valid files
150
+ logger.debug("📁 Creating temporary directory for batch upload...")
151
+ with tempfile.TemporaryDirectory(dir=os.getenv("TMPDIR", "/tmp")) as temp_dir:
152
+ for file_path in valid_files:
153
+ filename = os.path.basename(file_path)
154
+ dest_path = os.path.join(temp_dir, filename)
155
+ shutil.copy(file_path, dest_path)
156
+ logger.debug(f"📄 Copied: {filename}")
157
+
158
+ logger.info("🚀 Starting batch upload to Hugging Face...")
159
+ # Batch upload all files in the temporary folder.
160
+ try:
161
+ response = upload_folder(
162
+ folder_path=temp_dir,
163
+ repo_id=repo_id,
164
+ repo_type=repo_type,
165
+ path_in_repo=folder_name,
166
+ commit_message="Batch upload files"
167
+ )
168
+ logger.info("✅ Batch upload completed successfully")
169
+ except Exception as e:
170
+ logger.error(f"❌ Batch upload failed: {e}")
171
+ return {"response": f"Upload failed: {e}", "permalink": None, "short_permalink": None} if create_permalink else []
172
+
173
+ # Construct external URLs for each uploaded file.
174
+ base_url_external = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}"
175
+ individual_links = []
176
+ for file_path in valid_files:
177
+ filename = os.path.basename(file_path)
178
+ link = f"{base_url_external}/{filename}"
179
+ individual_links.append(link)
180
+ logger.debug(f"🔗 Generated link: {link}")
181
+
182
+ # Handle permalink creation if requested
183
+ if create_permalink:
184
+ logger.info("🔗 Attempting to create permalink...")
185
+ permalink = generate_permalink(valid_files, base_url_external, permalink_viewer_url)
186
+ if permalink:
187
+ logger.info(f"✅ Generated permalink: {permalink}")
188
+ logger.debug("🔗 Creating short URL...")
189
+ status, short_id = gen_full_url(
190
+ full_url=permalink,
191
+ repo_id=HF_REPO_ID,
192
+ json_file=SHORTENER_JSON_FILE
193
+ )
194
+ if status in ["created_short", "success_retrieved_short", "exists_match"]:
195
+ permalink_short = f"https://{permalink_viewer_url}/?sid={short_id}"
196
+ logger.info(f"✅ Created short permalink: {permalink_short}")
197
+ else:
198
+ permalink_short = None
199
+ logger.warning(f"⚠️ URL shortening failed: {status} for {permalink}")
200
+
201
+ return {
202
+ "response": response,
203
+ "permalink": permalink,
204
+ "short_permalink": permalink_short
205
+ }
206
+ else:
207
+ logger.warning("⚠️ Permalink generation failed (criteria not met)")
208
+ return {
209
+ "response": response,
210
+ "permalink": None,
211
+ "short_permalink": None
212
+ }
213
+
214
+ # Return individual tuples for each file
215
+ logger.info(f"📋 Returning individual links for {len(individual_links)} files")
216
+ return [(response, link) for link in individual_links]
217
+
218
+ def _generate_short_id(length=8):
219
+ """Generates a random base64 URL-safe string."""
220
+ return base64.urlsafe_b64encode(os.urandom(length * 2))[:length].decode('utf-8')
221
+
222
+ def _get_json_from_repo(repo_id, json_file_name, repo_type="dataset"):
223
+ """Downloads and loads the JSON file from the repo. Returns empty list if not found or error."""
224
+ try:
225
+ login(token=HF_API_TOKEN)
226
+ json_path = hf_hub_download(
227
+ repo_id=repo_id,
228
+ filename=json_file_name,
229
+ repo_type=repo_type,
230
+ token=HF_API_TOKEN
231
+ )
232
+ with open(json_path, 'r') as f:
233
+ data = json.load(f)
234
+ os.remove(json_path)
235
+ return data
236
+ except RepositoryNotFoundError:
237
+ logger.warning(f"Repository {repo_id} not found.")
238
+ return []
239
+ except EntryNotFoundError:
240
+ logger.warning(f"JSON file {json_file_name} not found in {repo_id}. Initializing with empty list.")
241
+ return []
242
+ except json.JSONDecodeError:
243
+ logger.error(f"Error decoding JSON from {json_file_name}. Returning empty list.")
244
+ return []
245
+ except Exception as e:
246
+ logger.error(f"An unexpected error occurred while fetching {json_file_name}: {e}")
247
+ return []
248
+
249
+ def _get_files_from_repo(repo_id, file_name, repo_type="dataset"):
250
+ """Downloads and loads the file from the repo. File must be in upload_file_types. Returns empty list if not found or error."""
251
+ filename = os.path.basename(file_name)
252
+ ext = os.path.splitext(file_name)[1].lower()
253
+ if ext not in upload_file_types:
254
+ logger.error(f"File {filename} with extension {ext} is not allowed for upload.")
255
+ return None
256
+ else:
257
+ try:
258
+ login(token=HF_API_TOKEN)
259
+ file_path = hf_hub_download(
260
+ repo_id=repo_id,
261
+ filename=file_name,
262
+ repo_type=repo_type,
263
+ token=HF_API_TOKEN
264
+ )
265
+ if not file_path:
266
+ return None
267
+ return file_path
268
+ except RepositoryNotFoundError:
269
+ logger.warning(f"Repository {repo_id} not found.")
270
+ return None
271
+ except EntryNotFoundError:
272
+ logger.warning(f"file {file_name} not found in {repo_id}. Initializing with empty list.")
273
+ return None
274
+ except Exception as e:
275
+ logger.error(f"Error fetching {file_name} from {repo_id}: {e}")
276
+ return None
277
+
278
+ def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
279
+ """Uploads the JSON data to the specified file in the repo."""
280
+ try:
281
+ login(token=HF_API_TOKEN)
282
+ api = HfApi()
283
+ # Use a temporary directory specified by TMPDIR or default to system temp
284
+ temp_dir_for_json = os.getenv("TMPDIR", tempfile.gettempdir())
285
+ os.makedirs(temp_dir_for_json, exist_ok=True)
286
+
287
+ with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json", dir=temp_dir_for_json) as tmp_file:
288
+ json.dump(data, tmp_file, indent=2)
289
+ tmp_file_path = tmp_file.name
290
+
291
+ logger.info(f"📤 Uploading JSON data to {json_file_name}...")
292
+ api.upload_file(
293
+ path_or_fileobj=tmp_file_path,
294
+ path_in_repo=json_file_name,
295
+ repo_id=repo_id,
296
+ repo_type=repo_type,
297
+ commit_message=f"Update {json_file_name}"
298
+ )
299
+ os.remove(tmp_file_path) # Clean up temporary file
300
+ logger.info("✅ JSON data uploaded successfully")
301
+ return True
302
+ except Exception as e:
303
+ logger.error(f"Failed to upload {json_file_name} to {repo_id}: {e}")
304
+ if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
305
+ os.remove(tmp_file_path) # Ensure cleanup on error too
306
+ return False
307
+
308
+ def _find_url_in_json(data, short_url=None, full_url=None):
309
+ """
310
+ Searches the JSON data.
311
+ If short_url is provided, returns the corresponding full_url or None.
312
+ If full_url is provided, returns the corresponding short_url or None.
313
+ """
314
+ if not data: # Handles cases where data might be None or empty
315
+ return None
316
+ if short_url:
317
+ for item in data:
318
+ if item.get("short_url") == short_url:
319
+ return item.get("full_url")
320
+ if full_url:
321
+ for item in data:
322
+ if item.get("full_url") == full_url:
323
+ return item.get("short_url")
324
+ return None
325
+
326
+ def _add_url_to_json(data, short_url, full_url):
327
+ """Adds a new short_url/full_url pair to the data. Returns updated data."""
328
+ if data is None:
329
+ data = []
330
+ data.append({"short_url": short_url, "full_url": full_url})
331
+ return data
332
+
333
+ def 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"):
334
+ """
335
+ Manages short URLs and their corresponding full URLs in a JSON file stored in a Hugging Face repository.
336
+
337
+ - If short_url is provided, attempts to retrieve and return the full_url.
338
+ - If full_url is provided, attempts to retrieve an existing short_url or creates a new one, stores it, and returns the short_url.
339
+ - If both are provided, checks for consistency or creates a new entry.
340
+ - If neither is provided, or repo_id is missing, returns an error status.
341
+
342
+ Returns:
343
+ tuple: (status_message, result_url)
344
+ status_message can be "success", "created", "exists", "error", "not_found".
345
+ result_url is the relevant URL (short or full) or None if an error occurs or not found.
346
+ """
347
+ if not repo_id:
348
+ return "error_repo_id_missing", None
349
+ if not short_url and not full_url:
350
+ return "error_no_input", None
351
+
352
+ login(token=HF_API_TOKEN) # Ensure login at the beginning
353
+ url_data = _get_json_from_repo(repo_id, json_file, repo_type)
354
+
355
+ # Case 1: Only short_url provided (lookup full_url)
356
+ if short_url and not full_url:
357
+ found_full_url = _find_url_in_json(url_data, short_url=short_url)
358
+ return ("success_retrieved_full", found_full_url) if found_full_url else ("not_found_short", None)
359
+
360
+ # Case 2: Only full_url provided (lookup or create short_url)
361
+ if full_url and not short_url:
362
+ existing_short_url = _find_url_in_json(url_data, full_url=full_url)
363
+ if existing_short_url:
364
+ return "success_retrieved_short", existing_short_url
365
+ else:
366
+ # Create new short_url
367
+ new_short_id = _generate_short_id()
368
+ url_data = _add_url_to_json(url_data, new_short_id, full_url)
369
+ if _upload_json_to_repo(url_data, repo_id, json_file, repo_type):
370
+ return "created_short", new_short_id
371
+ else:
372
+ return "error_upload", None
373
+
374
+ # Case 3: Both short_url and full_url provided
375
+ if short_url and full_url:
376
+ found_full_for_short = _find_url_in_json(url_data, short_url=short_url)
377
+ found_short_for_full = _find_url_in_json(url_data, full_url=full_url)
378
+
379
+ if found_full_for_short == full_url:
380
+ return "exists_match", short_url
381
+ if found_full_for_short is not None and found_full_for_short != full_url:
382
+ return "error_conflict_short_exists_different_full", short_url
383
+ if found_short_for_full is not None and found_short_for_full != short_url:
384
+ return "error_conflict_full_exists_different_short", found_short_for_full
385
+
386
+ # If short_url is provided and not found, or full_url is provided and not found,
387
+ # or neither is found, then create a new entry with the provided short_url and full_url.
388
+ # This effectively allows specifying a custom short_url if it's not already taken.
389
+ url_data = _add_url_to_json(url_data, short_url, full_url)
390
+ if _upload_json_to_repo(url_data, repo_id, json_file, repo_type):
391
+ return "created_specific_pair", short_url
392
+ else:
393
+ return "error_upload", None
394
+
395
+ return "error_unhandled_case", None # Should not be reached
396
+
397
+ def _encrypt_private_key(private_key: str, password: str = None) -> str:
398
+ """
399
+ Basic encryption for private keys. In production, use proper encryption like Fernet.
400
+
401
+ Note: This is a simplified encryption for demonstration. In production environments,
402
+ use proper encryption libraries like cryptography.fernet.Fernet with secure key derivation.
403
+
404
+ Args:
405
+ private_key (str): The private key to encrypt
406
+ password (str, optional): Password for encryption. If None, uses a default method.
407
+
408
+ Returns:
409
+ str: Base64 encoded encrypted private key
410
+ """
411
+ # WARNING: This is a basic XOR encryption for demo purposes only
412
+ # In production, use proper encryption like Fernet from cryptography library
413
+ if not password:
414
+ password = "default_encryption_key" # In production, use secure key derivation
415
+
416
+ encrypted_bytes = []
417
+ for i, char in enumerate(private_key):
418
+ encrypted_bytes.append(ord(char) ^ ord(password[i % len(password)]))
419
+
420
+ encrypted_data = bytes(encrypted_bytes)
421
+ return base64.b64encode(encrypted_data).decode('utf-8')
422
+
423
+ def _decrypt_private_key(encrypted_private_key: str, password: str = None) -> str:
424
+ """
425
+ Basic decryption for private keys. In production, use proper decryption like Fernet.
426
+
427
+ Args:
428
+ encrypted_private_key (str): Base64 encoded encrypted private key
429
+ password (str, optional): Password for decryption. If None, uses a default method.
430
+
431
+ Returns:
432
+ str: Decrypted private key
433
+ """
434
+ # WARNING: This is a basic XOR decryption for demo purposes only
435
+ if not password:
436
+ password = "default_encryption_key" # In production, use secure key derivation
437
+
438
+ encrypted_data = base64.b64decode(encrypted_private_key)
439
+ decrypted_chars = []
440
+ for i, byte in enumerate(encrypted_data):
441
+ decrypted_chars.append(chr(byte ^ ord(password[i % len(password)])))
442
+
443
+ return ''.join(decrypted_chars)
444
+
445
+ def store_issuer_keypair(issuer_id: str, public_key: str, private_key: str, repo_id: str = None) -> bool:
446
+ """
447
+ Store cryptographic keys for an issuer in the private Hugging Face repository.
448
+
449
+ **IMPORTANT: This function requires a PRIVATE Hugging Face repository to ensure
450
+ the security of stored private keys. Never use this with public repositories.**
451
+
452
+ The keys are stored in the following structure:
453
+ keys/issuers/{issuer_id}/
454
+ ├── private_key.json (encrypted)
455
+ └── public_key.json
456
+
457
+ Args:
458
+ issuer_id (str): Unique identifier for the issuer (e.g., "https://example.edu/issuers/565049")
459
+ public_key (str): Multibase-encoded public key
460
+ private_key (str): Multibase-encoded private key (will be encrypted before storage)
461
+ repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
462
+
463
+ Returns:
464
+ bool: True if keys were stored successfully, False otherwise
465
+
466
+ Raises:
467
+ ValueError: If issuer_id, public_key, or private_key are empty
468
+ Exception: If repository operations fail
469
+
470
+ Example:
471
+ >>> public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
472
+ >>> private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
473
+ >>> success = store_issuer_keypair("https://example.edu/issuers/565049", public_key, private_key)
474
+ >>> print(f"Keys stored: {success}")
475
+ """
476
+ if not issuer_id or not public_key or not private_key:
477
+ logger.error("❌ Missing required parameters: issuer_id, public_key, and private_key are required")
478
+ raise ValueError("issuer_id, public_key, and private_key are required")
479
+
480
+ if not repo_id:
481
+ repo_id = HF_REPO_ID
482
+ logger.debug(f"🔧 Using default repository: {repo_id}")
483
+
484
+ # Sanitize issuer_id for use as folder name
485
+ safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
486
+ logger.info(f"🔑 Storing keypair for issuer: {issuer_id}")
487
+ logger.debug(f"🗂️ Safe issuer ID: {safe_issuer_id}")
488
+
489
+ try:
490
+ # Encrypt the private key before storage
491
+ encrypted_private_key = _encrypt_private_key(private_key)
492
+ logger.debug("🔐 Private key encrypted successfully")
493
+
494
+ # Prepare key data structures
495
+ private_key_data = {
496
+ "issuer_id": issuer_id,
497
+ "encrypted_private_key": encrypted_private_key,
498
+ "key_type": "Ed25519VerificationKey2020",
499
+ "created_at": datetime.now(timezone.utc).isoformat(),
500
+ "encryption_method": "basic_xor" # In production, use proper encryption
501
+ }
502
+
503
+ public_key_data = {
504
+ "issuer_id": issuer_id,
505
+ "public_key": public_key,
506
+ "key_type": "Ed25519VerificationKey2020",
507
+ "created_at": datetime.now(timezone.utc).isoformat()
508
+ }
509
+
510
+ logger.info("📤 Uploading private key...")
511
+ # Store private key
512
+ private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
513
+ private_key_success = _upload_json_to_repo(private_key_data, repo_id, private_key_path, "dataset")
514
+
515
+ logger.info("📤 Uploading public key...")
516
+ # Store public key
517
+ public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
518
+ public_key_success = _upload_json_to_repo(public_key_data, repo_id, public_key_path, "dataset")
519
+
520
+ # Update global verification methods registry
521
+ if private_key_success and public_key_success:
522
+ logger.info("📋 Updating verification methods registry...")
523
+ _update_verification_methods_registry(issuer_id, safe_issuer_id, public_key, repo_id)
524
+ logger.info("✅ Keypair stored successfully and registry updated")
525
+ else:
526
+ logger.error("❌ Failed to store one or both keys")
527
+
528
+ return private_key_success and public_key_success
529
+
530
+ except Exception as e:
531
+ logger.error(f"💥 Error storing issuer keypair for {issuer_id}: {e}")
532
+ return False
533
+
534
+ def get_issuer_keypair(issuer_id: str, repo_id: str = None) -> Tuple[Optional[str], Optional[str]]:
535
+ """
536
+ Retrieve stored cryptographic keys for an issuer from the private Hugging Face repository.
537
+
538
+ **IMPORTANT: This function accesses a PRIVATE Hugging Face repository containing
539
+ encrypted private keys. Ensure proper access control and security measures.**
540
+
541
+ Args:
542
+ issuer_id (str): Unique identifier for the issuer
543
+ repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
544
+
545
+ Returns:
546
+ Tuple[Optional[str], Optional[str]]: (public_key, private_key) or (None, None) if not found
547
+
548
+ Raises:
549
+ ValueError: If issuer_id is empty
550
+ Exception: If repository operations fail or decryption fails
551
+
552
+ Example:
553
+ >>> public_key, private_key = get_issuer_keypair("https://example.edu/issuers/565049")
554
+ >>> if public_key and private_key:
555
+ ... print("Keys retrieved successfully")
556
+ ... else:
557
+ ... print("Keys not found")
558
+ """
559
+ if not issuer_id:
560
+ logger.error("❌ issuer_id is required")
561
+ raise ValueError("issuer_id is required")
562
+
563
+ if not repo_id:
564
+ repo_id = HF_REPO_ID
565
+ logger.debug(f"🔧 Using default repository: {repo_id}")
566
+
567
+ # Sanitize issuer_id for use as folder name
568
+ safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
569
+ logger.info(f"🔍 Retrieving keypair for issuer: {issuer_id}")
570
+ logger.debug(f"🗂️ Safe issuer ID: {safe_issuer_id}")
571
+
572
+ try:
573
+ logger.debug("📥 Retrieving public key...")
574
+ # Retrieve public key
575
+ public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
576
+ public_key_data = _get_json_from_repo(repo_id, public_key_path, "dataset")
577
+
578
+ logger.debug("📥 Retrieving private key...")
579
+ # Retrieve private key
580
+ private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
581
+ private_key_data = _get_json_from_repo(repo_id, private_key_path, "dataset")
582
+
583
+ if not public_key_data or not private_key_data:
584
+ logger.warning(f"⚠️ Keys not found for issuer {issuer_id}")
585
+ return None, None
586
+
587
+ # Extract and decrypt private key
588
+ encrypted_private_key = private_key_data.get("encrypted_private_key")
589
+ if not encrypted_private_key:
590
+ logger.error(f"❌ No encrypted private key found for issuer {issuer_id}")
591
+ return None, None
592
+
593
+ logger.debug("🔓 Decrypting private key...")
594
+ decrypted_private_key = _decrypt_private_key(encrypted_private_key)
595
+ public_key = public_key_data.get("public_key")
596
+
597
+ logger.info(f"✅ Successfully retrieved keypair for issuer {issuer_id}")
598
+ return public_key, decrypted_private_key
599
+
600
+ except Exception as e:
601
+ logger.error(f"💥 Error retrieving issuer keypair for {issuer_id}: {e}")
602
+ return None, None
603
+
604
+ def _update_verification_methods_registry(issuer_id: str, safe_issuer_id: str, public_key: str, repo_id: str):
605
+ """
606
+ Update the global verification methods registry with new issuer public key.
607
+
608
+ Args:
609
+ issuer_id (str): Original issuer ID
610
+ safe_issuer_id (str): Sanitized issuer ID for file system
611
+ public_key (str): Public key to register
612
+ repo_id (str): Repository ID
613
+ """
614
+ try:
615
+ registry_path = "keys/global/verification_methods.json"
616
+ registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
617
+
618
+ if not registry_data:
619
+ registry_data = {"verification_methods": []}
620
+
621
+ # Check if issuer already exists in registry
622
+ existing_entry = None
623
+ for i, method in enumerate(registry_data.get("verification_methods", [])):
624
+ if method.get("issuer_id") == issuer_id:
625
+ existing_entry = i
626
+ break
627
+
628
+ # Create new verification method entry
629
+ verification_method = {
630
+ "issuer_id": issuer_id,
631
+ "safe_issuer_id": safe_issuer_id,
632
+ "public_key": public_key,
633
+ "key_type": "Ed25519VerificationKey2020",
634
+ "updated_at": datetime.now(timezone.utc).isoformat()
635
+ }
636
+
637
+ if existing_entry is not None:
638
+ # Update existing entry
639
+ registry_data["verification_methods"][existing_entry] = verification_method
640
+ logger.info(f"♻️ Updated verification method for issuer {issuer_id}")
641
+ else:
642
+ # Add new entry
643
+ registry_data["verification_methods"].append(verification_method)
644
+ logger.info(f"➕ Added new verification method for issuer {issuer_id}")
645
+
646
+ # Upload updated registry
647
+ _upload_json_to_repo(registry_data, repo_id, registry_path, "dataset")
648
+ logger.info("✅ Verification methods registry updated successfully")
649
+
650
+ except Exception as e:
651
+ logger.error(f"Error updating verification methods registry: {e}")
652
+
653
+ def get_verification_methods_registry(repo_id: str = None) -> Dict[str, Any]:
654
+ """
655
+ Retrieve the global verification methods registry.
656
+
657
+ Args:
658
+ repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
659
+
660
+ Returns:
661
+ Dict[str, Any]: Registry data containing all verification methods
662
+ """
663
+ if not repo_id:
664
+ repo_id = HF_REPO_ID
665
+
666
+ try:
667
+ registry_path = "keys/global/verification_methods.json"
668
+ registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
669
+ return registry_data if registry_data else {"verification_methods": []}
670
+ except Exception as e:
671
+ logger.error(f"Error retrieving verification methods registry: {e}")
672
+ return {"verification_methods": []}
673
+
674
+ def list_issuer_ids(repo_id: str = None) -> List[str]:
675
+ """
676
+ List all issuer IDs that have stored keys in the repository.
677
+
678
+ Args:
679
+ repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
680
+
681
+ Returns:
682
+ List[str]: List of issuer IDs
683
+ """
684
+ if not repo_id:
685
+ repo_id = HF_REPO_ID
686
+
687
+ try:
688
+ registry = get_verification_methods_registry(repo_id)
689
+ return [method["issuer_id"] for method in registry.get("verification_methods", [])]
690
+ except Exception as e:
691
+ logger.error(f"Error listing issuer IDs: {e}")
692
+ return []
693
+
694
+ def _list_repo_folders(repo_id: str, path_prefix: str, repo_type: str = "dataset") -> List[str]:
695
+ """
696
+ List folder names under a given path in a HuggingFace repository.
697
+
698
+ Args:
699
+ repo_id: The repository ID on Hugging Face
700
+ path_prefix: The path prefix to list folders under (e.g., "leaderboards/daily/2025-01-27")
701
+ repo_type: Repository type ("dataset", "model", "space"). Default is "dataset".
702
+
703
+ Returns:
704
+ List of folder names (not full paths) found under the path_prefix.
705
+ Returns empty list if path not found or on error.
706
+ """
707
+ try:
708
+ login(token=HF_API_TOKEN)
709
+ api = HfApi()
710
+
711
+ # List all files in the repo under the prefix
712
+ # The list_repo_files returns file paths, so we extract unique folder names
713
+ all_files = api.list_repo_files(
714
+ repo_id=repo_id,
715
+ repo_type=repo_type,
716
+ token=HF_API_TOKEN
717
+ )
718
+
719
+ # Ensure path_prefix ends with /
720
+ if path_prefix and not path_prefix.endswith("/"):
721
+ path_prefix = path_prefix + "/"
722
+
723
+ folders = set()
724
+ for file_path in all_files:
725
+ if file_path.startswith(path_prefix):
726
+ # Get the relative path after the prefix
727
+ relative_path = file_path[len(path_prefix):]
728
+ # Extract the first folder name (before any /)
729
+ if "/" in relative_path:
730
+ folder_name = relative_path.split("/")[0]
731
+ folders.add(folder_name)
732
+
733
+ return sorted(list(folders))
734
+
735
+ except RepositoryNotFoundError:
736
+ logger.warning(f"Repository {repo_id} not found.")
737
+ return []
738
+ except Exception as e:
739
+ logger.error(f"Error listing folders in {repo_id}/{path_prefix}: {e}")
740
+ return []
741
+
742
+
743
+ def _list_repo_files_in_folder(repo_id: str, folder_path: str, repo_type: str = "dataset") -> List[str]:
744
+ """
745
+ List file names (not full paths) directly under a folder in a HuggingFace repository.
746
+
747
+ Args:
748
+ repo_id: The repository ID on Hugging Face
749
+ folder_path: The folder path to list files under
750
+ repo_type: Repository type. Default is "dataset".
751
+
752
+ Returns:
753
+ List of file names found directly in the folder.
754
+ """
755
+ try:
756
+ login(token=HF_API_TOKEN)
757
+ api = HfApi()
758
+
759
+ all_files = api.list_repo_files(
760
+ repo_id=repo_id,
761
+ repo_type=repo_type,
762
+ token=HF_API_TOKEN
763
+ )
764
+
765
+ # Ensure folder_path ends with /
766
+ if folder_path and not folder_path.endswith("/"):
767
+ folder_path = folder_path + "/"
768
+
769
+ files = []
770
+ for file_path in all_files:
771
+ if file_path.startswith(folder_path):
772
+ relative_path = file_path[len(folder_path):]
773
+ # Only include files directly in this folder (no subdirectories)
774
+ if "/" not in relative_path and relative_path:
775
+ files.append(relative_path)
776
+
777
+ return sorted(files)
778
+
779
+ except RepositoryNotFoundError:
780
+ logger.warning(f"Repository {repo_id} not found.")
781
+ return []
782
+ except Exception as e:
783
+ logger.error(f"Error listing files in {repo_id}/{folder_path}: {e}")
784
+ return []
785
+
786
+ if __name__ == "__main__":
787
+ issuer_id = "https://example.edu/issuers/565049"
788
+ # Example usage
789
+ public_key, private_key = get_issuer_keypair(issuer_id)
790
+ print(f"Public Key: {public_key}")
791
+ print(f"Private Key: {private_key}")
792
+
793
+ # Example to store keys
794
+ store_issuer_keypair(issuer_id, public_key, private_key)
795
+
796
+ # Example to list issuer IDs
797
+ issuer_ids = list_issuer_ids()
798
+
799
+ print(f"Issuer IDs: {issuer_ids}")
battlewords/ui.py CHANGED
@@ -1,6 +1,6 @@
1
  from __future__ import annotations
2
  from . import __version__ as version
3
- from typing import Iterable, Tuple, Optional
4
  import streamlit as st
5
  import streamlit.components.v1 as components
6
 
@@ -20,6 +20,8 @@ from .models import Coord, GameState, Puzzle
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
  from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
22
  from .modules.version_info import versions_html
 
 
23
 
24
  # --- Spinner context manager for custom spinner ---
25
  class CustomSpinner:
@@ -471,6 +473,37 @@ def _render_grid(show_grid_ticks: bool = True):
471
  unsafe_allow_html=True,
472
  )
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  grid_container = st.container()
475
  with grid_container:
476
  for r in range(size):
@@ -1174,6 +1207,69 @@ def _render_game_over(state: GameState):
1174
  if visible:
1175
  _game_over_dialog(state)
1176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1177
  def run_app():
1178
  start_root_fade_in(0.1)
1179
 
@@ -1183,8 +1279,16 @@ def run_app():
1183
  except Exception:
1184
  params = {}
1185
 
 
 
 
 
 
 
 
 
1186
  # Handle overlay dismissal
1187
- if params.get("overlay") == "0":
1188
  # Clear param and remember to hide overlay this session
1189
  try:
1190
  st.query_params.clear()
@@ -1198,7 +1302,7 @@ def run_app():
1198
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1199
  st.session_state["initial_page_loaded"] = True
1200
 
1201
- # Basic branch: challenge mode and shared games are disabled
1202
 
1203
  if st.session_state.get("needs_initialization", True):
1204
  spinner_placeholder = st.empty()
 
1
  from __future__ import annotations
2
  from . import __version__ as version
3
+ from typing import Iterable, Tuple, Optional, Any
4
  import streamlit as st
5
  import streamlit.components.v1 as components
6
 
 
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
  from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
22
  from .modules.version_info import versions_html
23
+ from .modules.constants import HF_REPO_ID, HALL_OF_LEGENDS_FILE
24
+ from .modules.storage import _get_json_from_repo, _upload_json_to_repo
25
 
26
  # --- Spinner context manager for custom spinner ---
27
  class CustomSpinner:
 
473
  unsafe_allow_html=True,
474
  )
475
 
476
+ # Legendary-only Hall of Legends save prompt
477
+ run_uid = f"{getattr(state.puzzle, 'uid', '')}"
478
+ if state.score >= 46:
479
+ st.markdown("### Hall of Legends")
480
+ st.caption("You reached Legendary. Save this run to the Hall of Legends?")
481
+
482
+ if st.button("Save to Hall of Legends", key="save_hall_of_legends"):
483
+ hall_entry = {
484
+ "uid": run_uid,
485
+ "score": int(state.score),
486
+ "tier": "Legendary",
487
+ "puzzle_words": [w.text for w in state.puzzle.words],
488
+ "points_by_word": dict(st.session_state.get("points_by_word", {})),
489
+ "start_time": (state.start_time.isoformat() if state.start_time else ""),
490
+ "end_time": (state.end_time.isoformat() if state.end_time else ""),
491
+ "elapsed_seconds": elapsed_seconds,
492
+ "game_mode": state.game_mode,
493
+ "wordlist": st.session_state.get("selected_wordlist", ""),
494
+ "metadata": {"app_version": version},
495
+ }
496
+
497
+ ok, message = _save_hall_entry(hall_entry)
498
+ st.session_state["hall_save_message"] = message
499
+ st.session_state["hall_saved_uid"] = run_uid if ok else ""
500
+
501
+ if st.session_state.get("hall_saved_uid") == run_uid:
502
+ st.success(st.session_state.get("hall_save_message", "Saved to Hall of Legends."))
503
+ st.markdown("[View Hall of Legends](?page=hall)")
504
+ elif st.session_state.get("hall_save_message"):
505
+ st.info(st.session_state.get("hall_save_message"))
506
+
507
  grid_container = st.container()
508
  with grid_container:
509
  for r in range(size):
 
1207
  if visible:
1208
  _game_over_dialog(state)
1209
 
1210
+
1211
+ def _normalize_query_value(value: Any) -> str:
1212
+ if isinstance(value, list):
1213
+ return str(value[0]) if value else ""
1214
+ if value is None:
1215
+ return ""
1216
+ return str(value)
1217
+
1218
+
1219
+ def _load_hall_entries() -> list[dict[str, Any]]:
1220
+ if not HF_REPO_ID:
1221
+ return []
1222
+ data = _get_json_from_repo(HF_REPO_ID, HALL_OF_LEGENDS_FILE, "dataset")
1223
+ return data if isinstance(data, list) else []
1224
+
1225
+
1226
+ def _save_hall_entry(entry: dict[str, Any]) -> tuple[bool, str]:
1227
+ if not HF_REPO_ID:
1228
+ return False, "HF_REPO_ID is not configured."
1229
+
1230
+ entries = _load_hall_entries()
1231
+ uid = str(entry.get("uid", "")).strip()
1232
+
1233
+ if uid and any(str(e.get("uid", "")) == uid for e in entries):
1234
+ return True, "Already saved to Hall of Legends."
1235
+
1236
+ entries.append(entry)
1237
+ entries.sort(key=lambda x: (-int(x.get("score", 0)), int(x.get("elapsed_seconds", 999999))))
1238
+
1239
+ if _upload_json_to_repo(entries, HF_REPO_ID, HALL_OF_LEGENDS_FILE, "dataset"):
1240
+ return True, "Saved to Hall of Legends."
1241
+ return False, "Unable to save to Hall of Legends."
1242
+
1243
+
1244
+ def _render_hall_page() -> None:
1245
+ st.title("Hall of Legends")
1246
+ st.caption("Legendary runs only (score 46+) • Local game, HF-backed Hall storage")
1247
+
1248
+ entries = [e for e in _load_hall_entries() if int(e.get("score", 0)) >= 46]
1249
+ entries.sort(key=lambda x: (-int(x.get("score", 0)), int(x.get("elapsed_seconds", 999999))))
1250
+
1251
+ if not entries:
1252
+ st.info("No Hall of Legends entries yet.")
1253
+ return
1254
+
1255
+ table_rows = []
1256
+ for idx, entry in enumerate(entries[:50], 1):
1257
+ table_rows.append(
1258
+ {
1259
+ "Rank": idx,
1260
+ "Score": int(entry.get("score", 0)),
1261
+ "Tier": entry.get("tier", "Legendary"),
1262
+ "Time (s)": int(entry.get("elapsed_seconds", 0)),
1263
+ "Wordlist": entry.get("wordlist", ""),
1264
+ "Saved": entry.get("end_time", ""),
1265
+ "UID": entry.get("uid", ""),
1266
+ }
1267
+ )
1268
+
1269
+ st.dataframe(table_rows, width="stretch", hide_index=True)
1270
+
1271
+ st.markdown("[Return to game](?overlay=0)")
1272
+
1273
  def run_app():
1274
  start_root_fade_in(0.1)
1275
 
 
1279
  except Exception:
1280
  params = {}
1281
 
1282
+ page = _normalize_query_value(params.get("page"))
1283
+
1284
+ # Hall of Legends page route
1285
+ if page == "hall":
1286
+ _render_hall_page()
1287
+ finish_root_fade_in(0.35)
1288
+ return
1289
+
1290
  # Handle overlay dismissal
1291
+ if _normalize_query_value(params.get("overlay")) == "0":
1292
  # Clear param and remember to hide overlay this session
1293
  try:
1294
  st.query_params.clear()
 
1302
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1303
  st.session_state["initial_page_loaded"] = True
1304
 
1305
+ # Basic branch: game page
1306
 
1307
  if st.session_state.get("needs_initialization", True):
1308
  spinner_placeholder = st.empty()
specs/basic.mdx CHANGED
@@ -41,9 +41,11 @@ This document defines the “basic” version scope for Battlewords on the `basi
41
  - Any settings-related UI should be omitted/disabled in this branch.
42
 
43
  ### Leaderboard
44
- - The app **does not need** a leaderboard.
45
- - No local or remote leaderboard storage is required.
46
- - No UI navigation or pages for leaderboard are required.
 
 
47
 
48
  ### AI generation of wordlists
49
  - The basic branch does **not** include AI-generated wordlists or HF-model-backed word generation.
 
41
  - Any settings-related UI should be omitted/disabled in this branch.
42
 
43
  ### Leaderboard
44
+ - The app **does not require** a full-featured leaderboard. However, a minimal, local-only "Hall of Legends" view is allowed as a small exception to the "no leaderboard" rule.
45
+ - The Hall of Legends MUST only store runs that achieve the Legendary tier (score >= 46).
46
+ - Hall of Legends storage MUST be local-only (no remote sync) and use the JSON Lines schema described in the requirements document.
47
+ - The Hall of Legends page MUST NOT be linked from the main UI in standard basic builds. It may be accessible via an explicit query parameter for developer inspection only (for example `?page=hall`).
48
+ - No sidebar, no footer navigation, and no authentication for Hall of Legends.
49
 
50
  ### AI generation of wordlists
51
  - The basic branch does **not** include AI-generated wordlists or HF-model-backed word generation.
specs/requirements.mdx CHANGED
@@ -70,6 +70,20 @@ This document captures the implementation requirements for the **basic** branch
70
  - Triggered when all six words are guessed or when all word letters are revealed.
71
  - Shows a summary with score and tier.
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  ### Scoring tiers
74
  - OK: < 34
75
  - Good: 34–37
@@ -94,7 +108,6 @@ This document captures the implementation requirements for the **basic** branch
94
  ## Out of Scope (Basic)
95
  - Settings page
96
  - Leaderboards
97
- - Challenge mode / remote storage (removed)
98
- - PWA support (removed)
99
  - Audio
100
  - Tests
 
70
  - Triggered when all six words are guessed or when all word letters are revealed.
71
  - Shows a summary with score and tier.
72
 
73
+ ### Hall of Legends (persistent saves)
74
+ - When a player finishes a game with a Legendary score (46 or higher) the app may offer to save the run to the "Hall of Legends".
75
+ - Only games with score >= 46 are eligible to be saved.
76
+ - Hall persistence uses existing HF-backed storage patterns (no new local Hall data file).
77
+ - Hall routing MUST be available in development and production via `?page=hall`.
78
+ - Hall JSON MUST remain compatible with Wrdler leaderboard schema so existing loaders/tools can parse it:
79
+ - top-level required keys: `challenge_id`, `entry_type`, `game_mode`, `grid_size`, `puzzle_options`, `users`, `created_at`, `version`, `show_incorrect_guesses`, `enable_free_letters`, `wordlist_source`, `game_title`, `max_display_entries`
80
+ - `entry_type` MUST be `"hall"`
81
+ - `users[*]` required keys: `uid`, `username`, `word_list`, `score`, `time`, `timestamp`
82
+ - `users[*].score` MUST be `>= 46` for every Hall entry
83
+ - `users[*].word_list_difficulty` is optional
84
+ - Hall read behavior: display top runs sorted by score desc then time asc.
85
+ - The basic branch keeps Hall simple: no authentication and no pagination beyond a capped display list.
86
+
87
  ### Scoring tiers
88
  - OK: < 34
89
  - Good: 34–37
 
108
  ## Out of Scope (Basic)
109
  - Settings page
110
  - Leaderboards
111
+ - Full daily/weekly leaderboard systems (Hall-only scope)
 
112
  - Audio
113
  - Tests
specs/specs.mdx CHANGED
@@ -52,6 +52,42 @@ Battlewords is inspired by the classic Battleship game, but uses words instead o
52
  - Custom loading spinner/overlay for transitions
53
  - No sidebar, no settings page, no leaderboards
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  ## Word Lists
56
  - Local word list files under `battlewords/words/`.
57
  - Loaded via `battlewords.word_loader.load_word_list()`.
 
52
  - Custom loading spinner/overlay for transitions
53
  - No sidebar, no settings page, no leaderboards
54
 
55
+ ## Hall of Legends (Simplified Leaderboard)
56
+
57
+ - Purpose: a minimal, read-only listing of saved Legendary runs (score >= 46). The Hall of Legends replaces the previous leaderboard concept with a lightweight, local-only view.
58
+ - Accessibility: Hall of Legends MUST be accessible in both development and production via query-param routing using `?page=hall`.
59
+ - Data source: reads and writes the same HF-backed JSON document used for Hall saves (same structure as existing Wrdler leaderboard JSON format).
60
+ - Display fields: rank (by score, then elapsed time), player-run uid, score, tier, puzzle wordlist name, elapsed time, and timestamp. Points-by-word breakdown is optional and shown in an expandable row.
61
+ - Page constraints: no authentication, no pagination (show latest 50 entries), and client-side sorting is allowed.
62
+ - Export: provide a single CSV export button that streams the currently-displayed rows (client-side only).
63
+
64
+ ### Hall of Legends JSON format (compatible)
65
+
66
+ Hall entries MUST use the same top-level schema as Wrdler leaderboard JSON files for compatibility:
67
+
68
+ - `challenge_id` (string): Hall group identifier (example: `hall-of-legends/classic-classic-12x12`)
69
+ - `entry_type` (string): MUST be `"hall"`
70
+ - `game_mode` (string)
71
+ - `grid_size` (integer): `12` for Battlewords
72
+ - `puzzle_options` (object): includes `spacer` and `may_overlap`
73
+ - `users` (array): list of saved runs
74
+ - each user object includes:
75
+ - `uid` (string)
76
+ - `username` (string)
77
+ - `word_list` (array of six words)
78
+ - `score` (number, MUST be `>= 46`)
79
+ - `time` (number of seconds)
80
+ - `timestamp` (ISO-8601 string)
81
+ - `word_list_difficulty` (number, optional)
82
+ - `created_at` (ISO-8601 string)
83
+ - `version` (string)
84
+ - `show_incorrect_guesses` (boolean)
85
+ - `enable_free_letters` (boolean)
86
+ - `wordlist_source` (string)
87
+ - `game_title` (string)
88
+ - `max_display_entries` (integer)
89
+
90
+
91
  ## Word Lists
92
  - Local word list files under `battlewords/words/`.
93
  - Loaded via `battlewords.word_loader.load_word_list()`.