Spaces:
Runtime error
Runtime error
| """app.py — SnapQuest. | |
| ARCHITECTURE (the fix): | |
| - We create OUR OWN FastAPI app. It owns "/" and serves index.html. | |
| - Gradio is mounted as a SUB-APPLICATION at "/gradio" via gr.mount_gradio_app(). | |
| - Gradio's own API endpoints therefore live at: | |
| /gradio/gradio_api/run/upload | |
| /gradio/gradio_api/run/start | |
| /gradio/gradio_api/run/action | |
| /gradio/gradio_api/run/voice | |
| - index.html calls these via a RELATIVE path: `/gradio/gradio_api/run/<endpoint>` | |
| (relative — works on the HF Space URL, localhost, or any deployment domain). | |
| This is the ONLY architecture that survives `launch()` / Spaces SDK boot, | |
| because we never ask Gradio to be the root ASGI app — so it can never | |
| override our "/" route. uvicorn serves OUR app; gradio is just a mounted | |
| child. | |
| """ | |
| import base64 | |
| import json | |
| import os | |
| import tempfile | |
| import gradio as gr | |
| from fastapi import FastAPI | |
| from fastapi.responses import FileResponse | |
| from engine_photo import start_photo_game, take_photo_action | |
| from dungeon import current_room, can_advance | |
| from game_data import roll_loot | |
| from vision import analyze_scene | |
| from voice import clean_for_speech, speak, transcribe_audio | |
| _HERE = os.path.dirname(os.path.abspath(__file__)) | |
| # ── Session store ───────────────────────────────────────────────────────────── | |
| _SESSIONS: dict = {} | |
| def _get(sid: str) -> dict: | |
| if sid not in _SESSIONS: | |
| _SESSIONS[sid] = {"photos": [], "selected_class": None, "game": {}} | |
| return _SESSIONS[sid] | |
| def _save_b64(data_url: str) -> str: | |
| header, b64 = (data_url.split(",", 1) if "," in data_url else ("", data_url)) | |
| ext = ".png" if "png" in header else ".webp" if "webp" in header else ".jpg" | |
| raw = base64.b64decode(b64) | |
| fd, path = tempfile.mkstemp(prefix="sq_", suffix=ext, dir="/tmp") | |
| with os.fdopen(fd, "wb") as f: | |
| f.write(raw) | |
| return path | |
| def _fmt_story(state: dict) -> str: | |
| if not state.get("rooms"): | |
| return "The dungeon awaits..." | |
| room = current_room(state) | |
| diff = room.get("difficulty", "").upper() | |
| sym = {"EASY": "◆", "MEDIUM": "◈", "HARD": "⬡"}.get(diff, "◆") | |
| lines = [f"{'─'*5} {sym} {room.get('scene_name','Unknown')} {sym} {'─'*5}", "", | |
| room.get("scene_description", "")] | |
| for entry in state.get("history", [])[-8:]: | |
| lines.append(f"\n▷ {entry.get('action','')}") | |
| st = entry.get("response", {}).get("story", "") | |
| if st: lines.append(st) | |
| if can_advance(state): | |
| lines.append("\n\n[ Room cleared — click DESCEND DEEPER ]") | |
| return "\n".join(str(l) for l in lines if l is not None) | |
| def _photos_summary(photos): | |
| return [{"has": bool(p.get("path")), "objects": p.get("objects", []), "name": p.get("name", "")} | |
| for p in photos] | |
| def _serialize_state(gs: dict) -> dict: | |
| rooms = gs.get("rooms", []) | |
| idx = gs.get("room_index", 0) | |
| room = rooms[min(idx, len(rooms)-1)] if rooms else {} | |
| boss = room.get("boss") if room.get("is_boss") else None | |
| return { | |
| "hp": gs.get("hp", 100), "max_hp": gs.get("max_hp", 100), | |
| "xp": gs.get("xp", 0), "level": 1 + gs.get("xp", 0) // 100, | |
| "inventory": gs.get("inventory", []), | |
| "character_class": gs.get("character_class", ""), | |
| "room_index": idx, "total_rooms": len(rooms), | |
| "room": { | |
| "scene_name": room.get("scene_name", ""), | |
| "scene_description": room.get("scene_description", ""), | |
| "atmosphere": room.get("atmosphere", ""), | |
| "objects_found": room.get("objects_found", []), | |
| "difficulty": room.get("difficulty", "easy"), | |
| "is_boss": room.get("is_boss", False), | |
| "cleared": room.get("cleared", False), | |
| "enemy_alive": room.get("enemy_alive", True), | |
| "enemy_hp": room.get("enemy_hp", 30), | |
| "enemy_max_hp": room.get("enemy_max_hp", 30), | |
| }, | |
| "boss": {"name": boss.get("name",""), "hp": boss.get("hp",0), | |
| "max_hp": boss.get("max_hp",100), "alive": boss.get("alive",True) | |
| } if boss else None, | |
| "choices": gs.get("current_choices", ["Look around","Move forward","Hold position"]), | |
| "story": _fmt_story(gs), | |
| "rooms_summary": [{"cleared": r.get("cleared",False), "is_boss": r.get("is_boss",False), | |
| "scene_name": r.get("scene_name","")} for r in rooms], | |
| } | |
| # ── Gradio API functions ────────────────────────────────────────────────────── | |
| def api_upload(sid: str, slot: float, data_url: str, name: str) -> str: | |
| app = _get(sid) | |
| slot = int(slot) | |
| photos = list(app.get("photos", [])) | |
| while len(photos) <= slot: photos.append({}) | |
| if not data_url: | |
| photos[slot] = {}; app["photos"] = photos | |
| return json.dumps({"ok": True, "photos": _photos_summary(photos)}) | |
| try: | |
| path = _save_b64(data_url) | |
| scene = analyze_scene(path, "Rogue") | |
| objects = scene.get("objects_found", []) | |
| except Exception as exc: | |
| return json.dumps({"ok": False, "error": str(exc)}) | |
| photos[slot] = {"path": path, "objects": objects, "name": name} | |
| app["photos"] = photos | |
| return json.dumps({"ok": True, "photos": _photos_summary(photos)}) | |
| def api_start(sid: str, character_class: str) -> str: | |
| app = _get(sid) | |
| paths = [p["path"] for p in app.get("photos", []) if p.get("path")] | |
| if not paths: | |
| return json.dumps({"ok": False, "error": "No photos uploaded"}) | |
| try: | |
| gs = start_photo_game(paths, character_class) | |
| app["game"] = gs; app["selected_class"] = character_class | |
| return json.dumps({"ok": True, "state": _serialize_state(gs)}) | |
| except Exception as exc: | |
| return json.dumps({"ok": False, "error": str(exc)}) | |
| def api_action(sid: str, text: str) -> str: | |
| app = _get(sid) | |
| gs = app.get("game", {}) | |
| if not gs.get("rooms"): | |
| return json.dumps({"ok": False, "error": "No active game"}) | |
| if not text.strip(): | |
| return json.dumps({"ok": True, "state": _serialize_state(gs), "loot": []}) | |
| try: | |
| was_cleared = current_room(gs).get("cleared", False) | |
| new_gs, parsed = take_photo_action(gs, text) | |
| loot = [] | |
| room_after = current_room(new_gs) | |
| if room_after.get("cleared") and not was_cleared: | |
| n = 3 if room_after.get("is_boss") else 2 | |
| loot = roll_loot(n) | |
| new_gs["inventory"] = new_gs.get("inventory", []) + loot | |
| app["game"] = new_gs | |
| audio_b64 = "" | |
| try: | |
| txt = clean_for_speech(parsed) | |
| if txt: | |
| apath = speak(txt) | |
| with open(apath, "rb") as f: | |
| audio_b64 = base64.b64encode(f.read()).decode() | |
| except Exception: | |
| pass | |
| return json.dumps({"ok": True, "state": _serialize_state(new_gs), | |
| "loot": loot, "audio_b64": audio_b64, | |
| "story": parsed.get("story", "")}) | |
| except Exception as exc: | |
| return json.dumps({"ok": False, "error": str(exc)}) | |
| def api_voice(sid: str, audio_b64: str) -> str: | |
| if not audio_b64: | |
| return json.dumps({"ok": False, "text": ""}) | |
| try: | |
| raw = base64.b64decode(audio_b64) | |
| fd, path = tempfile.mkstemp(suffix=".wav", dir="/tmp") | |
| with os.fdopen(fd, "wb") as f: f.write(raw) | |
| text = transcribe_audio(path) | |
| if not text: | |
| return json.dumps({"ok": False, "text": ""}) | |
| result = json.loads(api_action(sid, text)) | |
| result["transcribed"] = text | |
| return json.dumps(result) | |
| except Exception as exc: | |
| return json.dumps({"ok": False, "error": str(exc), "text": ""}) | |
| # ── Gradio Blocks (API-only, hidden) ─────────────────────────────────────────── | |
| with gr.Blocks(title="SnapQuest API") as gradio_app: | |
| gr.Markdown("### SnapQuest API backend\n\nThis surface only exposes API endpoints " | |
| "consumed by the game UI at the [root URL](/). " | |
| "Visit `/` for the actual game.") | |
| with gr.Row(visible=False): | |
| gr.Button().click(api_upload, | |
| inputs=[gr.Textbox(), gr.Number(), gr.Textbox(), gr.Textbox()], | |
| outputs=[gr.Textbox()], api_name="upload") | |
| with gr.Row(visible=False): | |
| gr.Button().click(api_start, | |
| inputs=[gr.Textbox(), gr.Textbox()], | |
| outputs=[gr.Textbox()], api_name="start") | |
| with gr.Row(visible=False): | |
| gr.Button().click(api_action, | |
| inputs=[gr.Textbox(), gr.Textbox()], | |
| outputs=[gr.Textbox()], api_name="action") | |
| with gr.Row(visible=False): | |
| gr.Button().click(api_voice, | |
| inputs=[gr.Textbox(), gr.Textbox()], | |
| outputs=[gr.Textbox()], api_name="voice") | |
| # ── FastAPI app (THIS is root — owns "/") ────────────────────────────────────── | |
| fastapi_app = FastAPI(title="SnapQuest") | |
| async def serve_index(): | |
| return FileResponse(os.path.join(_HERE, "index.html")) | |
| async def serve_game(): | |
| # Alias kept for backwards-compat with anything linking to /game | |
| return FileResponse(os.path.join(_HERE, "index.html")) | |
| async def health(): | |
| return {"status": "ok"} | |
| # Mount Gradio as a SUB-app at /gradio. Its API lives at | |
| # /gradio/gradio_api/run/<endpoint> — Gradio never touches "/". | |
| fastapi_app = gr.mount_gradio_app(fastapi_app, gradio_app, path="/gradio") | |
| # Expose `app` for `python app.py` / uvicorn / HF Spaces SDK detection. | |
| app = fastapi_app | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) |