Spaces:
Runtime error
Runtime error
feat: rich RPG UI — medieval aesthetic, embedded JS bridge, Nemotron DM backend, no head= bug
3112162 | """engine_photo.py — SnapQuest dungeon engine. | |
| DM backend priority (automatic): | |
| 1. SNAPQUEST_DM_MODEL env var → use that HF model (e.g. nvidia/Nemotron-Mini-4B-Instruct) | |
| 2. Default → Qwen/Qwen2.5-3B-Instruct via HF Inference API (free, no auth) | |
| 3. Both fail → rule-based fallback (always playable) | |
| Set SNAPQUEST_DM_MODEL=nvidia/Nemotron-Mini-4B-Instruct to target NVIDIA prize track. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import urllib.error | |
| import urllib.request | |
| from copy import deepcopy | |
| from typing import Any | |
| from vision import analyze_scene | |
| from dungeon import ( | |
| build_rooms, current_room, can_advance, advance_room, | |
| apply_combat, minimap_html, xp_bar_html, | |
| ) | |
| # ── DM model config ────────────────────────────────────────────────────────── | |
| _DEFAULT_DM_MODEL = "Qwen/Qwen2.5-3B-Instruct" | |
| _NEMOTRON_MODEL = "nvidia/Nemotron-Mini-4B-Instruct" | |
| DM_MODEL = os.environ.get("SNAPQUEST_DM_MODEL", _DEFAULT_DM_MODEL) | |
| HF_API_URL = f"https://api-inference.huggingface.co/models/{DM_MODEL}" | |
| HF_TIMEOUT = 45 | |
| HISTORY_WINDOW = 6 | |
| # ── Class config ───────────────────────────────────────────────────────────── | |
| STARTING_INVENTORY: dict[str, list[str]] = { | |
| "Swordsman": ["Iron Sword", "Shield", "Torch"], | |
| "Archer": ["Longbow", "Quiver", "Rope"], | |
| "Healer": ["Staff", "Healing Herbs", "Lantern"], | |
| "Rogue": ["Dagger", "Lockpick", "Smoke Bomb"], | |
| "Mage": ["Spellbook", "Crystal Orb", "Candle"], | |
| } | |
| CLASS_TONES: dict[str, str] = { | |
| "Swordsman": "You see defensible positions, chokepoints, and threats in every shadow.", | |
| "Archer": "You calculate sightlines, distances, and elevated vantage points.", | |
| "Healer": "You sense life energy, danger, and what needs tending.", | |
| "Rogue": "You see shadows, hidden passages, and things of value.", | |
| "Mage": "You read magical residue in objects; ordinary things reveal arcane secrets.", | |
| } | |
| GENERIC_CHOICES = [ | |
| "Look around carefully", | |
| "Move forward cautiously", | |
| "Hold your position and listen", | |
| ] | |
| DM_SYSTEM = """You are a Dungeon Master narrating a dark fantasy adventure set inside a real room. | |
| Output EXACTLY this format, nothing else: | |
| SCENE: [2 atmospheric sentences] | |
| STORY: [2 sentences about what just happened] | |
| CHOICE: | |
| 1. [action mentioning a real object] | |
| 2. [different action mentioning a real object] | |
| 3. [third action] | |
| RULES: Never write placeholder text like "[action]". Keep under 120 words. Stay in character.""" | |
| # ── Normalisation ───────────────────────────────────────────────────────────── | |
| def _norm_class(character_class: str) -> str: | |
| lu = {k.lower(): k for k in STARTING_INVENTORY} | |
| out = lu.get(character_class.strip().lower()) | |
| if not out: | |
| raise ValueError(f"Unknown class '{character_class}'.") | |
| return out | |
| # ── Game start ──────────────────────────────────────────────────────────────── | |
| def start_photo_game( | |
| image_paths: list[str], | |
| character_class: str, | |
| ) -> dict[str, Any]: | |
| class_name = _norm_class(character_class) | |
| photo_scenes: list[dict[str, Any]] = [] | |
| for path in image_paths: | |
| scene = analyze_scene(path, class_name) | |
| if "atmosphere" not in scene: | |
| scene["atmosphere"] = scene.get("scene_description", "") | |
| photo_scenes.append(scene) | |
| rooms = build_rooms(photo_scenes) | |
| rooms[0]["visited"] = True | |
| state: dict[str, Any] = { | |
| "hp": 100, "max_hp": 100, "xp": 0, | |
| "inventory": STARTING_INVENTORY[class_name].copy(), | |
| "character_class": class_name, | |
| "rooms": rooms, | |
| "room_index": 0, | |
| "turn": 0, | |
| "history": [], | |
| "photo_scene": rooms[0], | |
| "current_scene": rooms[0]["scene_description"], | |
| "current_choices": rooms[0]["choices"], | |
| "quests": [], | |
| "world": "photo", | |
| "last_parsed": {}, | |
| } | |
| return state | |
| # ── Action ──────────────────────────────────────────────────────────────────── | |
| def take_photo_action( | |
| state: dict[str, Any], | |
| player_action: str, | |
| ) -> tuple[dict[str, Any], dict[str, Any]]: | |
| updated = deepcopy(state) | |
| action_lower = player_action.strip().lower() | |
| advance_keywords = {"advance", "next room", "go deeper", "move on", | |
| "enter next", "proceed", "deeper", "descend"} | |
| is_advance = any(kw in action_lower for kw in advance_keywords) | |
| if is_advance and can_advance(updated): | |
| updated = advance_room(updated) | |
| room = current_room(updated) | |
| updated["photo_scene"] = room | |
| updated["current_scene"] = room["scene_description"] | |
| updated["current_choices"] = room["choices"] | |
| intro_text = "" | |
| if room.get("is_boss") and room.get("boss"): | |
| intro_text = "\n\n" + room["boss"]["intro"] | |
| parsed: dict[str, Any] = { | |
| "scene": room["scene_description"] + intro_text, | |
| "story": f"You enter {room['scene_name']}. The air changes.", | |
| "choices": room["choices"], | |
| "raw": "", | |
| } | |
| else: | |
| prompt = _build_prompt(updated, player_action.strip()) | |
| raw_text = _call_dm(prompt) | |
| parsed = parse_dm_response(raw_text) | |
| updated, combat_msg = apply_combat(updated, player_action) | |
| if combat_msg: | |
| parsed["story"] = (parsed.get("story") or "") + combat_msg | |
| room = current_room(updated) | |
| if room.get("cleared") and can_advance(updated): | |
| parsed["choices"] = parsed.get("choices", list(GENERIC_CHOICES)) | |
| if "Go deeper" not in str(parsed["choices"]): | |
| parsed["choices"][-1] = "Descend deeper into the dungeon" | |
| updated["current_scene"] = parsed.get("scene", updated.get("current_scene", "")) | |
| updated["current_choices"] = parsed.get("choices", GENERIC_CHOICES) | |
| updated["photo_scene"] = current_room(updated) | |
| updated["turn"] = int(updated.get("turn", 0)) + 1 | |
| updated["last_parsed"] = parsed | |
| history = list(updated.get("history", [])) | |
| history.append({"turn": updated["turn"], "action": player_action.strip(), "response": parsed}) | |
| updated["history"] = history[-HISTORY_WINDOW:] | |
| return updated, parsed | |
| # ── Prompt builder ──────────────────────────────────────────────────────────── | |
| def _build_prompt(state: dict[str, Any], player_action: str) -> str: | |
| room = current_room(state) | |
| class_name = state.get("character_class", "Swordsman") | |
| inv_str = ", ".join(state.get("inventory", [])) or "nothing" | |
| objects = room.get("objects_found", []) | |
| obj_str = ", ".join(objects) if objects else "unknown objects" | |
| turn_num = state.get("turn", 0) + 1 | |
| difficulty = room.get("difficulty", "normal").upper() | |
| is_boss = room.get("is_boss", False) | |
| boss_name = room.get("boss", {}).get("name", "") if is_boss else "" | |
| lines = [DM_SYSTEM, "", | |
| f"LOCATION: {room.get('scene_name', 'Unknown')} [DIFFICULTY: {difficulty}]"] | |
| if is_boss and boss_name: | |
| boss_hp = room.get("boss", {}).get("hp", "?") | |
| boss_max = room.get("boss", {}).get("max_hp", "?") | |
| lines.append(f"⚠ BOSS FIGHT: {boss_name} [{boss_hp}/{boss_max} HP]") | |
| lines += [ | |
| f"REAL OBJECTS: {obj_str}", | |
| f"CHARACTER: {class_name} — {CLASS_TONES.get(class_name, '')}", | |
| f"TURN {turn_num} | HP: {state.get('hp', 100)} | INV: {inv_str}", | |
| "", | |
| ] | |
| for entry in state.get("history", [])[-HISTORY_WINDOW:]: | |
| lines.append(f"Player: {entry.get('action', '')}") | |
| resp = entry.get("response", {}) | |
| dm_text = (resp.get("scene", "") + " " + resp.get("story", "")).strip() | |
| if dm_text: | |
| lines.append(f"DM: {dm_text}") | |
| lines.append("") | |
| lines.append(f"Player: {player_action}") | |
| lines.append("DM:") | |
| return "\n".join(lines) | |
| # ── DM caller ───────────────────────────────────────────────────────────────── | |
| def _call_dm(prompt: str) -> str: | |
| try: | |
| return _call_hf_inference(prompt) | |
| except Exception as exc: | |
| print(f"[engine] HF Inference failed ({exc}), using rule-based fallback.") | |
| return _rule_based_dm(prompt) | |
| def _call_hf_inference(prompt: str) -> str: | |
| headers = {"Content-Type": "application/json"} | |
| token = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN") | |
| if token: | |
| headers["Authorization"] = f"Bearer {token}" | |
| payload = json.dumps({ | |
| "inputs": prompt, | |
| "parameters": { | |
| "max_new_tokens": 300, | |
| "temperature": 0.75, | |
| "return_full_text": False, | |
| "stop": ["Player:", "---"], | |
| }, | |
| }).encode("utf-8") | |
| req = urllib.request.Request(HF_API_URL, data=payload, headers=headers, method="POST") | |
| try: | |
| with urllib.request.urlopen(req, timeout=HF_TIMEOUT) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) | |
| except urllib.error.HTTPError as e: | |
| body = e.read().decode("utf-8", errors="replace") | |
| raise RuntimeError(f"HF Inference HTTP {e.code}: {body[:200]}") from e | |
| except urllib.error.URLError as e: | |
| raise RuntimeError(f"HF Inference network error: {e.reason}") from e | |
| if isinstance(data, list) and data: | |
| return data[0].get("generated_text", "") | |
| if isinstance(data, dict) and "error" in data: | |
| raise RuntimeError(f"HF Inference model error: {data['error']}") | |
| return str(data) | |
| def _rule_based_dm(prompt: str) -> str: | |
| obj_match = re.search(r"REAL OBJECTS:\s*(.+)", prompt) | |
| objects = [o.strip() for o in obj_match.group(1).split(",")] if obj_match else ["the shadows"] | |
| action_m = re.search(r"Player:\s*(.+)\nDM:", prompt) | |
| action = action_m.group(1).strip() if action_m else "proceed" | |
| o1 = objects[0] if len(objects) > 0 else "the darkness" | |
| o2 = objects[1] if len(objects) > 1 else "the walls" | |
| o3 = objects[2] if len(objects) > 2 else "the floor" | |
| return ( | |
| f"SCENE: The chamber feels heavier after your last move. " | |
| f"The {o1} looms before you, casting strange shadows.\n" | |
| f"STORY: You {action.lower()} and discover something unexpected near the {o2}. " | |
| f"The dungeon shifts around you.\n" | |
| f"CHOICE:\n" | |
| f"1. Examine the {o1} more closely\n" | |
| f"2. Search behind the {o2} for hidden passages\n" | |
| f"3. Use the {o3} to your advantage" | |
| ) | |
| # ── Parser ──────────────────────────────────────────────────────────────────── | |
| def parse_dm_response(raw: str) -> dict[str, Any]: | |
| result: dict[str, Any] = { | |
| "scene": "", "story": "", "choices": list(GENERIC_CHOICES), "raw": raw, | |
| } | |
| scene_m = re.search(r"SCENE\s*:\s*(.+?)(?=STORY\s*:|CHOICE\s*:|$)", raw, re.IGNORECASE | re.DOTALL) | |
| if scene_m: | |
| result["scene"] = scene_m.group(1).strip() | |
| story_m = re.search(r"STORY\s*:\s*(.+?)(?=CHOICE\s*:|SCENE\s*:|$)", raw, re.IGNORECASE | re.DOTALL) | |
| if story_m: | |
| result["story"] = story_m.group(1).strip() | |
| choice_m = re.search(r"CHOICE\s*:\s*(.+?)$", raw, re.IGNORECASE | re.DOTALL) | |
| if choice_m: | |
| numbered = re.findall(r"^\s*[1-3][\.\)]\s*(.+)", choice_m.group(1), re.MULTILINE) | |
| real = [c.strip() for c in numbered if "[" not in c and len(c.strip()) > 6] | |
| if real: | |
| while len(real) < 3: | |
| real.append(GENERIC_CHOICES[len(real)]) | |
| result["choices"] = real[:3] | |
| if not result["scene"] and not result["story"]: | |
| result["story"] = raw.strip() | |
| result["choices"] = list(GENERIC_CHOICES) | |
| return result |