BattleWords / battlewords /game_storage.py
Surn's picture
0.2.29
ffe26fc
# file: battlewords/game_storage.py
"""
BattleWords-specific storage wrapper for HuggingFace storage operations.
This module provides high-level functions for saving and loading BattleWords games
using the shared storage module from battlewords.modules.
"""
__version__ = "0.1.3"
import json
import tempfile
import os
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional, Tuple
import logging
from urllib.parse import unquote
from battlewords.modules import (
upload_files_to_repo,
gen_full_url,
HF_REPO_ID,
SHORTENER_JSON_FILE,
SPACE_NAME
)
from battlewords.modules.storage import _get_json_from_repo
from battlewords.local_storage import save_json_to_file
from battlewords.word_loader import compute_word_difficulties
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def generate_uid() -> str:
"""
Generate a unique identifier for a game.
Format: YYYYMMDDTHHMMSSZ-RANDOM
Example: 20250123T153045Z-A7B9C2
Returns:
str: Unique game identifier
"""
import random
import string
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"{timestamp}-{random_suffix}"
def serialize_game_settings(
word_list: List[str],
username: str,
score: int,
time_seconds: int,
game_mode: str,
grid_size: int = 12,
spacer: int = 1,
may_overlap: bool = False,
wordlist_source: Optional[str] = None,
challenge_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Serialize game settings into a JSON-compatible dictionary.
Creates initial structure with one user's result.
Each user has their own uid and word_list.
Args:
word_list: List of words used in THIS user's game
username: Player's name
score: Final score achieved
time_seconds: Time taken to complete (in seconds)
game_mode: Game mode ("classic" or "too_easy")
grid_size: Grid size (default: 12)
spacer: Word spacing configuration (0-2, default: 1)
may_overlap: Whether words can overlap (default: False)
wordlist_source: Source file name (e.g., "classic.txt")
challenge_id: Optional challenge ID (generated if not provided)
Returns:
dict: Serialized game settings with users array
"""
if challenge_id is None:
challenge_id = generate_uid()
# Try compute difficulty using the source file; optional
difficulty_value: Optional[float] = None
try:
if wordlist_source:
words_dir = os.path.join(os.path.dirname(__file__), "words")
wordlist_path = os.path.join(words_dir, wordlist_source)
if os.path.exists(wordlist_path):
total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
difficulty_value = float(total_diff)
except Exception as _e:
# optional field, swallow errors
difficulty_value = None
# Build user result with desired ordering: uid, username, word_list, word_list_difficulty, score, time, timestamp
user_result = {
"uid": generate_uid(),
"username": username,
"word_list": word_list,
}
if difficulty_value is not None:
user_result["word_list_difficulty"] = difficulty_value
user_result["score"] = score
user_result["time"] = time_seconds
user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
settings = {
"challenge_id": challenge_id,
"game_mode": game_mode,
"grid_size": grid_size,
"puzzle_options": {
"spacer": spacer,
"may_overlap": may_overlap
},
"users": [user_result],
"created_at": datetime.now(timezone.utc).isoformat(),
"version": __version__
}
if wordlist_source:
settings["wordlist_source"] = wordlist_source
return settings
def add_user_result_to_game(
sid: str,
username: str,
word_list: List[str],
score: int,
time_seconds: int,
repo_id: Optional[str] = None
) -> bool:
"""
Add a user's result to an existing shared challenge.
Each user gets their own uid and word_list.
Args:
sid: Short ID of the existing challenge
username: Player's name
word_list: List of words THIS user played
score: Score achieved
time_seconds: Time taken (seconds)
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
Returns:
bool: True if successfully added, False otherwise
"""
if repo_id is None:
repo_id = HF_REPO_ID
logger.info(f"➕ Adding user result to challenge {sid}")
try:
# Load existing game settings
settings = load_game_from_sid(sid, repo_id)
if not settings:
logger.error(f"❌ Challenge not found: {sid}")
return False
# Compute optional difficulty using the saved wordlist_source if available
difficulty_value: Optional[float] = None
try:
wordlist_source = settings.get("wordlist_source")
if wordlist_source:
words_dir = os.path.join(os.path.dirname(__file__), "words")
wordlist_path = os.path.join(words_dir, wordlist_source)
if os.path.exists(wordlist_path):
total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
difficulty_value = float(total_diff)
except Exception:
difficulty_value = None
# Create new user result with ordering and optional difficulty
user_result = {
"uid": generate_uid(),
"username": username,
"word_list": word_list,
}
if difficulty_value is not None:
user_result["word_list_difficulty"] = difficulty_value
user_result["score"] = score
user_result["time"] = time_seconds
user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
# Add to users array
if "users" not in settings:
settings["users"] = []
settings["users"].append(user_result)
logger.info(f"👥 Now {len(settings['users'])} users in game")
# Get the file path from the sid
status, full_url = gen_full_url(
short_url=sid,
repo_id=repo_id,
json_file=SHORTENER_JSON_FILE
)
if status != "success_retrieved_full" or not full_url:
logger.error(f"❌ Could not resolve sid: {sid}")
return False
# Extract challenge_id from URL
url_parts = full_url.split("/resolve/main/")
if len(url_parts) != 2:
logger.error(f"❌ Invalid URL format: {full_url}")
return False
file_path = url_parts[1] # e.g., "games/{challenge_id}/settings.json"
challenge_id = file_path.split("/")[1] # Extract challenge_id
folder_name = f"games/{challenge_id}"
# Save updated settings back to HF
try:
with tempfile.TemporaryDirectory() as tmpdir:
settings_path = save_json_to_file(settings, tmpdir, "settings.json")
logger.info(f"📤 Updating {folder_name}/settings.json")
response = upload_files_to_repo(
files=[settings_path],
repo_id=repo_id,
folder_name=folder_name,
repo_type="dataset"
)
logger.info(f"✅ User result added for {username}")
return True
except Exception as e:
logger.error(f"❌ Failed to upload updated settings: {e}")
return False
except Exception as e:
logger.error(f"❌ Failed to add user result: {e}")
return False
def save_game_to_hf(
word_list: List[str],
username: str,
score: int,
time_seconds: int,
game_mode: str,
grid_size: int = 12,
spacer: int = 1,
may_overlap: bool = False,
repo_id: Optional[str] = None,
wordlist_source: Optional[str] = None
) -> Tuple[str, Optional[str], Optional[str]]:
"""
Save game settings to HuggingFace repository and generate shareable URL.
Creates a new game entry with the first user's result.
This function:
1. Generates a unique UID for the game
2. Serializes game settings to JSON with first user
3. Uploads settings.json to HF repo under games/{uid}/
4. Creates a shortened URL (sid) for sharing
5. Returns the full URL and short ID
Args:
word_list: List of words used in the game
username: Player's name
score: Final score achieved
time_seconds: Time taken to complete (in seconds)
game_mode: Game mode ("classic" or "too_easy")
grid_size: Grid size (default: 12)
spacer: Word spacing configuration (0-2, default: 1)
may_overlap: Whether words can overlap (default: False)
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
wordlist_source: Source wordlist file name (e.g., "classic.txt")
Returns:
tuple: (challenge_id, full_url, sid) where:
- challenge_id: Unique challenge identifier
- full_url: Full URL to settings.json
- sid: Shortened ID for sharing (8 characters)
Raises:
Exception: If upload or URL shortening fails
Example:
>>> uid, full_url, sid = save_game_to_hf(
... word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
... username="Alice",
... score=42,
... time_seconds=180,
... game_mode="classic",
... wordlist_source="classic.txt"
... )
>>> print(f"Share: https://{SPACE_NAME}/?game_id={sid}")
"""
if repo_id is None:
repo_id = HF_REPO_ID
logger.info(f"💾 Saving game to HuggingFace repo: {repo_id}")
# Generate challenge ID and serialize settings
challenge_id = generate_uid()
settings = serialize_game_settings(
word_list=word_list,
username=username,
score=score,
time_seconds=time_seconds,
game_mode=game_mode,
grid_size=grid_size,
spacer=spacer,
may_overlap=may_overlap,
challenge_id=challenge_id,
wordlist_source=wordlist_source
)
logger.debug(f"🆔 Generated Challenge ID: {challenge_id}")
# Write settings to a temp directory using a fixed filename 'settings.json'
folder_name = f"games/{challenge_id}"
try:
with tempfile.TemporaryDirectory() as tmpdir:
settings_path = save_json_to_file(settings, tmpdir, "settings.json")
logger.info(f"📤 Uploading to {folder_name}/settings.json")
# Upload to HF repo under games/{uid}/settings.json
response = upload_files_to_repo(
files=[settings_path],
repo_id=repo_id,
folder_name=folder_name,
repo_type="dataset"
)
# Construct full URL to settings.json
full_url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}/settings.json"
logger.info(f"✅ Uploaded: {full_url}")
# Generate short URL
logger.info("🔗 Creating short URL...")
status, sid = gen_full_url(
full_url=full_url,
repo_id=repo_id,
json_file=SHORTENER_JSON_FILE
)
if status in ["created_short", "success_retrieved_short", "exists_match"]:
logger.info(f"✅ Short ID created: {sid}")
share_url = f"https://{SPACE_NAME}/?game_id={sid}"
logger.info(f"🎮 Share URL: {share_url}")
return challenge_id, full_url, sid
else:
logger.warning(f"⚠️ URL shortening failed: {status}")
return challenge_id, full_url, None
except Exception as e:
logger.error(f"❌ Failed to save game: {e}")
raise
def load_game_from_sid(
sid: str,
repo_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Load game settings from a short ID (sid).
If settings.json cannot be found, return None and allow normal game loading.
Args:
sid: Short ID (8 characters) from shareable URL
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
Returns:
dict | None: Game settings or None if not found
dict: Challenge settings containing:
- challenge_id: Unique challenge identifier
- wordlist_source: Source wordlist file (e.g., "classic.txt")
- game_mode: Game mode
- grid_size: Grid size
- puzzle_options: Puzzle configuration (spacer, may_overlap)
- users: Array of user results, each with:
- uid: Unique user game identifier
- username: Player name
- word_list: Words THIS user played
- score: Score achieved
- time: Time taken (seconds)
- timestamp: When result was recorded
- created_at: When challenge was created
- version: Storage version
Returns None if sid not found or download fails
Example:
>>> settings = load_game_from_sid("abc12345")
>>> if settings:
... print(f"Challenge ID: {settings['challenge_id']}")
... print(f"Wordlist: {settings['wordlist_source']}")
... for user in settings['users']:
... print(f"{user['username']}: {user['score']} pts")
"""
if repo_id is None:
repo_id = HF_REPO_ID
logger.info(f"🔍 Loading game from sid: {sid}")
try:
# Resolve sid to full URL
status, full_url = gen_full_url(
short_url=sid,
repo_id=repo_id,
json_file=SHORTENER_JSON_FILE
)
if status != "success_retrieved_full" or not full_url:
logger.warning(f"⚠️ Could not resolve sid: {sid} (status: {status})")
return None
logger.info(f"✅ Resolved to: {full_url}")
# Extract the file path from the full URL
# URL format: https://huggingface.co/datasets/{repo_id}/resolve/main/{path}
# We need just the path part: games/{uid}/settings.json
try:
url_parts = full_url.split("/resolve/main/")
if len(url_parts) != 2:
logger.error(f"❌ Invalid URL format: {full_url}")
return None
file_path = url_parts[1]
logger.info(f"📥 Downloading {file_path} using authenticated API...")
settings = _get_json_from_repo(repo_id, file_path, repo_type="dataset")
if not settings:
logger.error(f"❌ settings.json not found for sid: {sid}. Loading normal game.")
return None
logger.info(f"✅ Loaded challenge: {settings.get('challenge_id', 'unknown')}")
users = settings.get('users', [])
logger.debug(f"Users in challenge: {len(users)}")
return settings
except Exception as e:
logger.error(f"❌ Failed to parse URL or download: {e}")
return None
except Exception as e:
logger.error(f"❌ Unexpected error loading game: {e}")
return None
def get_shareable_url(sid: str, base_url: str = None) -> str:
"""
Generate a shareable URL from a short ID.
If running locally, use localhost. Otherwise, use HuggingFace Space domain.
Additionally, if an "iframe_host" query parameter is present in the current
Streamlit request, it takes precedence and will be used as the base URL.
Args:
sid: Short ID (8 characters)
base_url: Optional override for the base URL (for testing or custom deployments)
Returns:
str: Full shareable URL
Example:
>>> url = get_shareable_url("abc12345")
>>> print(url)
https://surn-battlewords.hf.space/?game_id=abc12345
"""
import os
from battlewords.modules.constants import SPACE_NAME
# 0) If not explicitly provided, try to read iframe_host from Streamlit query params
if base_url is None:
try:
import streamlit as st # local import to avoid hard dependency
params = getattr(st, "query_params", None)
if params is None and hasattr(st, "experimental_get_query_params"):
params = st.experimental_get_query_params()
if params and "iframe_host" in params:
raw_host = params.get("iframe_host")
# st.query_params may return str or list[str]
if isinstance(raw_host, (list, tuple)):
raw_host = raw_host[0] if raw_host else None
if raw_host:
decoded = unquote(str(raw_host))
if decoded:
base_url = decoded
except Exception:
# Ignore any errors here and fall back to defaults below
pass
# 1) If base_url is provided (either parameter or iframe_host), use it directly
if base_url:
sep = '&' if '?' in base_url else '?'
return f"{base_url}{sep}game_id={sid}"
if os.environ.get("IS_LOCAL", "true").lower() == "true":
# 2) Check for local development (common Streamlit env vars)
port = os.environ.get("PORT") or os.environ.get("STREAMLIT_SERVER_PORT") or "8501"
host = os.environ.get("HOST") or os.environ.get("STREAMLIT_SERVER_ADDRESS") or "localhost"
if host in ("localhost", "127.0.0.1") or os.environ.get("IS_LOCAL", "").lower() == "true":
return f"http://{host}:{port}/?game_id={sid}"
# 3) Otherwise, build HuggingFace Space URL from SPACE_NAME
space = (SPACE_NAME or "surn/battlewords").lower().replace("/", "-")
return f"https://{space}.hf.space/?game_id={sid}"
if __name__ == "__main__":
# Example usage
print("BattleWords Game Storage Module")
print(f"Version: {__version__}")
print(f"Target Repository: {HF_REPO_ID}")
print(f"Space Name: {SPACE_NAME}")
# Example: Save a game
print("\n--- Example: Save Game ---")
try:
challenge_id, full_url, sid = save_game_to_hf(
word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
username="Alice",
score=42,
time_seconds=180,
game_mode="classic"
)
print(f"Challenge ID: {challenge_id}")
print(f"Full URL: {full_url}")
print(f"Short ID: {sid}")
print(f"Share: {get_shareable_url(sid)}")
except Exception as e:
print(f"Error: {e}")
# Example: Load a game
print("\n--- Example: Load Game ---")
if sid:
settings = load_game_from_sid(sid)
if settings:
print(f"Loaded Challenge: {settings['challenge_id']}")
print(f"Wordlist Source: {settings.get('wordlist_source', 'N/A')}")
users = settings.get('users', [])
print(f"Users: {len(users)}")
for user in users:
print(f" - {user['username']}: {user['score']} pts in {user['time']}s")