Spaces:
Sleeping
Sleeping
| """Write gateway for the Reachy Arcade global leaderboard. | |
| Validates the caller's HF OAuth token via whoami, then merges their new score | |
| into the Dataset's scores.json (one global best per game). | |
| """ | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| from datetime import datetime, timezone | |
| from typing import Optional | |
| import httpx | |
| from fastapi import FastAPI, Header, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from pydantic import BaseModel, Field | |
| logging.basicConfig(level=logging.INFO) | |
| log = logging.getLogger("arcade-api") | |
| DATASET_REPO = "cdeplanne/reachy-arcade-scores" | |
| SCORES_FILENAME = "scores.json" | |
| PERSONAL_BESTS_FILENAME = "personal_bests.json" | |
| GAME_KEYS = {"spaceship", "pong", "duckrun", "flappy", "boss", "rhythm"} | |
| NAME_MAX_LEN = 4 | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| if not HF_TOKEN: | |
| log.warning("HF_TOKEN env var is unset β writes will fail until it's configured.") | |
| api = HfApi(token=HF_TOKEN) | |
| # One global mutex avoids interleaved read-modify-write on scores.json. | |
| _write_lock = asyncio.Lock() | |
| class ScoreIn(BaseModel): | |
| game: str = Field(..., description="One of the GAME_KEYS") | |
| score: int = Field(..., ge=0, le=10_000_000) | |
| name: str = Field("AAAA", min_length=1, max_length=8) | |
| app = FastAPI(title="Reachy Arcade API") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[ | |
| "https://cdeplanne-reachy-arcade.static.hf.space", | |
| "http://localhost:8765", | |
| ], | |
| allow_methods=["GET", "POST", "OPTIONS"], | |
| allow_headers=["*"], | |
| ) | |
| def health(): | |
| return {"ok": True, "service": "reachy-arcade-api"} | |
| async def _whoami(token: str) -> Optional[str]: | |
| """Return the HF username for `token`, or None if invalid.""" | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get( | |
| "https://huggingface.co/api/whoami-v2", | |
| headers={"Authorization": f"Bearer {token}"}, | |
| ) | |
| if r.status_code != 200: | |
| return None | |
| data = r.json() | |
| return data.get("name") or data.get("preferred_username") or None | |
| def _load_json(filename: str) -> dict: | |
| """Pull the latest copy of `filename` from the Dataset, or {} if missing.""" | |
| try: | |
| path = hf_hub_download( | |
| repo_id=DATASET_REPO, | |
| repo_type="dataset", | |
| filename=filename, | |
| force_download=True, # always grab the live version | |
| token=HF_TOKEN, | |
| ) | |
| with open(path) as f: | |
| return json.load(f) | |
| except Exception as e: | |
| log.warning("could not load %s (%s); starting empty", filename, e) | |
| return {} | |
| def _save_json(filename: str, data: dict, commit_message: str) -> None: | |
| payload = json.dumps(data, separators=(",", ":")).encode("utf-8") | |
| api.upload_file( | |
| path_or_fileobj=payload, | |
| path_in_repo=filename, | |
| repo_id=DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message=commit_message, | |
| ) | |
| async def post_score(body: ScoreIn, authorization: str = Header(default="")): | |
| if not HF_TOKEN: | |
| raise HTTPException(503, "Backend missing HF_TOKEN") | |
| if not authorization.lower().startswith("bearer "): | |
| raise HTTPException(401, "Missing bearer token") | |
| user_token = authorization.split(" ", 1)[1].strip() | |
| if not user_token: | |
| raise HTTPException(401, "Empty bearer token") | |
| username = await _whoami(user_token) | |
| if not username: | |
| raise HTTPException(401, "Invalid HF token") | |
| if body.game not in GAME_KEYS: | |
| raise HTTPException(400, f"Unknown game '{body.game}'") | |
| clean_name = (body.name or "AAAA").strip().upper()[:NAME_MAX_LEN] or "AAAA" | |
| now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds") | |
| async with _write_lock: | |
| # ββ personal best (per-user) ββββββββββββββββββββββββββββββββββββββββ | |
| personal = _load_json(PERSONAL_BESTS_FILENAME) | |
| user_pb = personal.get(username) or {} | |
| prev_pb = int((user_pb.get(body.game) or {}).get("score") or 0) | |
| wrote_personal = False | |
| if body.score > prev_pb: | |
| user_pb[body.game] = { | |
| "score": body.score, | |
| "name": clean_name, | |
| "updated": now_iso, | |
| } | |
| personal[username] = user_pb | |
| wrote_personal = True | |
| # ββ global best (across all users) ββββββββββββββββββββββββββββββββββ | |
| scores = _load_json(SCORES_FILENAME) | |
| prev_global = scores.get(body.game) or {} | |
| prev_global_score = int(prev_global.get("score") or 0) | |
| wrote_global = False | |
| if body.score > prev_global_score: | |
| scores[body.game] = { | |
| "score": body.score, | |
| "name": clean_name, | |
| "owner": username, | |
| "updated": now_iso, | |
| } | |
| wrote_global = True | |
| if not wrote_personal and not wrote_global: | |
| return { | |
| "status": "not_best", | |
| "personal_best": prev_pb, | |
| "global_best": prev_global_score, | |
| "global_owner": prev_global.get("owner"), | |
| } | |
| try: | |
| if wrote_personal: | |
| _save_json(PERSONAL_BESTS_FILENAME, personal, f"PB: {username} {body.game}={body.score}") | |
| if wrote_global: | |
| _save_json(SCORES_FILENAME, scores, f"Global: {body.game}={body.score} by {username}") | |
| except Exception as e: | |
| log.exception("save failed") | |
| raise HTTPException(500, f"Could not save: {e}") | |
| return { | |
| "status": "saved", | |
| "personal_best": wrote_personal, | |
| "global_best": wrote_global, | |
| "game": body.game, | |
| "score": body.score, | |
| "owner": username, | |
| } | |