"""Gradio game UI for Myco โ LLM-powered forest adventure.""" import ast import json import copy import random import threading import logging from typing import Any, cast import gradio as gr from game.engine import ( collect_current, eat_current, follow_whisper, hf_companion_status, myco_reply, pick_current, study_current, check_auto_pickup, get_myco_narrative, RARITY_WEIGHTS, POISONOUS, get_myco_log, _LAST_REPLY, companion_reply, discover_mushroom, ) from game.catalog import load_mushrooms _CATALOG = None def _get_catalog(): global _CATALOG if _CATALOG is None: _CATALOG = list(load_mushrooms()) return _CATALOG from game.state import welcome_history from models.mushroom import Mushroom from ui.renderers import ( dex_markdown, forest_scene, game_intro_markdown, game_status_markdown, hero_markdown, home_forest_markdown, mushroom_card, progress_markdown, world_map_markdown, bubble ) from game.progression import current_area, next_area, next_mystery, revealed_mysteries from ui.interface import render_shroom_tab logger = logging.getLogger(__name__) EMPTY_COLLECTION = None START_POSITION = (1, 1) # --------------------------------------------------------------------------- # Autonomous roam state โ pure Python, no LLM for decisions # --------------------------------------------------------------------------- _ROAM_LOCK = threading.Lock() _ROAM = { "position": [1, 1], "visited": set(), "thought": "The forest is waking up...", "ticks": 0, } def _roam_next_pos(x, y): candidates = [] for dx, dy in [(0,-1),(0,1),(-1,0),(1,0)]: nx, ny = x+dx, y+dy if 0 <= nx <= 2 and 0 <= ny <= 2: w = 1 if (nx,ny) in _ROAM["visited"] else 4 candidates.extend([(nx,ny)]*w) return list(random.choice(candidates)) if candidates else [x, y] def _autonomous_tick(current, collection, active_mushrooms, position): """Pure Python: move Myco, check pickup, return updated state.""" with _ROAM_LOCK: x, y = _ROAM["position"] safe_mushrooms = list(active_mushrooms or []) coll = list(collection or []) # Check pickup at current position first if safe_mushrooms: current, coll, safe_mushrooms = check_auto_pickup( x, y, current, coll, safe_mushrooms ) # Move to next cell nx, ny = _roam_next_pos(x, y) _ROAM["position"] = [nx, ny] _ROAM["visited"].add((nx, ny)) _ROAM["ticks"] += 1 pos = (nx, ny) # Check pickup at new position if safe_mushrooms: current, coll, safe_mushrooms = check_auto_pickup( nx, ny, current, coll, safe_mushrooms ) return ( list(pos), forest_scene(current, coll, pos, active_mushrooms=safe_mushrooms), game_status_markdown(current, coll, pos), mushroom_card_from_state(current), current, coll, safe_mushrooms, gr.update(), dex_markdown(coll), progress_markdown(coll), ) # --------------------------------------------------------------------------- # JSON / history helpers # --------------------------------------------------------------------------- def force_parse_json_or_literal(s: str) -> Any: s = s.strip() if not (s.startswith("[") or s.startswith("{")): return s try: return json.loads(s) except Exception: pass try: return ast.literal_eval(s) except Exception: pass try: return json.loads(s.replace("None","null").replace("True","true").replace("False","false")) except Exception: pass return s def extract_clean_text(val: Any) -> str: if val is None: return "" if isinstance(val, str): v = val.strip() if (v.startswith("[") and v.endswith("]")) or (v.startswith("{") and v.endswith("}")): parsed = force_parse_json_or_literal(v) if isinstance(parsed, str): return parsed return extract_clean_text(parsed) return val if isinstance(val, dict): for k in ("text","content"): if k in val: return extract_clean_text(val[k]) return " ".join(extract_clean_text(v) for v in val.values() if v) if isinstance(val, list): return "\n".join(extract_clean_text(i) for i in val if i) return str(val) def _normalize_history(history): if not history: return [] if isinstance(history, str): history = force_parse_json_or_literal(history) if not isinstance(history, (list, tuple)): history = [history] raw = [] for item in history: if item is None: continue if isinstance(item, str): item = force_parse_json_or_literal(item) if isinstance(item, dict): role = item.get("role","assistant") content = item.get("content", item.get("text","")) raw.append({"role": role, "content": content}) elif isinstance(item, (list,tuple)) and len(item)==2: u, b = item if u: raw.append({"role":"user","content": extract_clean_text(u)}) if b: raw.append({"role":"assistant","content": extract_clean_text(b)}) else: raw.append({"role":"assistant","content": extract_clean_text(item)}) reconciled = [] for msg in raw: role = str(msg.get("role","assistant")).lower() if role not in ("user","assistant","system"): role = "assistant" content = str(extract_clean_text(msg.get("content",""))).strip() if not content: continue if reconciled and reconciled[-1]["role"] == role: reconciled[-1]["content"] += f"\n{content}" else: reconciled.append({"role": role, "content": content}) return reconciled def _denormalize_history(history): msgs = _normalize_history(history) out = [] i = 0 while i < len(msgs): role = msgs[i].get("role","assistant") content = msgs[i].get("content","") if role == "user": bot = "" if i+1 < len(msgs) and msgs[i+1].get("role") == "assistant": bot = msgs[i+1].get("content","") i += 2 else: i += 1 out.append((content, bot)) else: out.append(("", content)) i += 1 return out # --------------------------------------------------------------------------- # Mushroom card helper # --------------------------------------------------------------------------- def mushroom_card_from_state(current): if current is None: return mushroom_card(None) if isinstance(current, str): current = force_parse_json_or_literal(current) if not isinstance(current, dict): return mushroom_card(None) state = copy.copy(current) state.setdefault("name","Unknown Mushroom") state.setdefault("rarity","Common") state.setdefault("habitat","Unknown") state.setdefault("lore","No lore available.") try: m = Mushroom.from_dict(state) except Exception: return mushroom_card(None) return mushroom_card(m, state.get("clue"), state.get("secret"), state) # --------------------------------------------------------------------------- # Search โ spawns 4-6 mushrooms at random grid positions # --------------------------------------------------------------------------- def search_forest(position, collection): pos = _safe_pos(position) coll = list(collection or []) catalog = _get_catalog() # Spawn 4-6 mushrooms at random grid positions count = random.randint(4, 6) used = set() batch = [] for _ in range(count): if len(used) >= 9: break while True: mx, my = random.randint(0,2), random.randint(0,2) if (mx,my) not in used: break used.add((mx,my)) weights = [RARITY_WEIGHTS.get(getattr(m,"rarity","Common"),12) for m in catalog] m = random.choices(catalog, weights=weights, k=1)[0] batch.append({ "name": m.name, "rarity": m.rarity, "habitat": getattr(m,"habitat","Forest"), "lore": getattr(m,"lore",""), "edible": getattr(m,"edible","Unknown"), "magic": getattr(m,"magic","Unknown"), "danger": getattr(m,"danger","Unknown"), "poison": "Yes" if m.name in POISONOUS else "No", "mush_x": mx, "mush_y": my, "picked": "No", "studied": "No", "clue": "Move Myco onto this mushroom to collect it!", "score_delta": str({"Common":10,"Rare":35,"Legendary":100}.get(m.rarity,10)), }) current = { "name": "Mushroom Patch", "rarity": "Multiple", "poison": "No", "game_over": "No", "picked": "No", "studied": "No", "encounter": f"{count} mushrooms sprouted! Move Myco to collect them!", "event_title": "Spore Influx", "event_emoji": "๐", "mystery_title": "The Wrong Memory", "mystery_next": "Keep collecting to reveal the next chapter.", "score_total": str(sum(int(b.get("score_delta","10")) for b in batch if b.get("poison")=="No")), "health": "3", "reward_text": f"Move Myco to pick up {count} mushrooms!", "active_mushrooms": batch, } history = list(welcome_history()) + [ {"role":"assistant","content":f"๐ Whoa! {count} mushrooms just sprouted across the clearing! Move me around to collect them โ but watch for the poisonous ones!"} ] # The "Current Discovery" card shows batch[0]. Make current_mushroom match # that same mushroom (merged with the patch's game-state fields like # mystery_title/event_title/score_total/active_mushrooms), so the action # buttons (Collect/Pick/Study/Whisper/Eat) operate on what's actually shown # instead of the abstract "Mushroom Patch" placeholder. spotlight = batch[0] if batch else None current_mushroom_state = {**current, **spotlight} if spotlight else current return ( forest_scene(current_mushroom_state, coll, pos, active_mushrooms=batch), game_status_markdown(current_mushroom_state, coll, pos), mushroom_card_from_state(current_mushroom_state), current_mushroom_state, batch, # active_mushrooms state _normalize_history(history), dex_markdown(coll), progress_markdown(coll), ) # --------------------------------------------------------------------------- # Movement โ collision detection built in, no LLM call # --------------------------------------------------------------------------- def move_player(position, direction, current, collection, active_mushrooms): x, y = _safe_pos(position) if direction == "north": y = max(0, y-1) elif direction == "south": y = min(2, y+1) elif direction == "west": x = max(0, x-1) elif direction == "east": x = min(2, x+1) pos = (x, y) safe_mushrooms = list(active_mushrooms or []) coll = list(collection or []) # Collision check โ pure Python, no LLM current, coll, safe_mushrooms = check_auto_pickup( x, y, current, coll, safe_mushrooms ) # Update roam tracker with _ROAM_LOCK: _ROAM["position"] = [x, y] _ROAM["visited"].add((x, y)) return ( pos, forest_scene(current, coll, pos, active_mushrooms=safe_mushrooms), game_status_markdown(current, coll, pos), mushroom_card_from_state(current), current, coll, safe_mushrooms, gr.update(), # leave chat untouched dex_markdown(coll), progress_markdown(coll), ) # --------------------------------------------------------------------------- # Other player actions # --------------------------------------------------------------------------- def ask_companion(message, history, current, collection, position): legacy = _denormalize_history(history) result = myco_reply(message, legacy, current, collection, position) if isinstance(result, (tuple,list)) and len(result)==2: return result[0], _normalize_history(result[1]) return "", _normalize_history(result) def collect_discovery(current, collection, history, position): legacy = _denormalize_history(history) updated_coll, updated_hist = collect_current(current, collection, legacy) pos = _safe_pos(position) if isinstance(current,str): current = force_parse_json_or_literal(current) return ( updated_coll, forest_scene(current, updated_coll, pos), game_status_markdown(current, updated_coll, pos), dex_markdown(updated_coll), world_map_markdown(updated_coll), _normalize_history(updated_hist), progress_markdown(updated_coll), ) def pick_discovery(current, collection, history, position): legacy = _denormalize_history(history) updated_coll, updated_current, updated_hist = pick_current(current, collection, legacy) if isinstance(updated_current,str): updated_current = force_parse_json_or_literal(updated_current) pos = _safe_pos(position) return ( updated_coll, updated_current, forest_scene(updated_current, updated_coll, pos), game_status_markdown(updated_current, updated_coll, pos), mushroom_card_from_state(updated_current), dex_markdown(updated_coll), world_map_markdown(updated_coll), _normalize_history(updated_hist), progress_markdown(updated_coll), ) def follow_story_whisper(current, collection, history, position): legacy = _denormalize_history(history) updated_current, updated_hist = follow_whisper(current, collection, legacy) if isinstance(updated_current,str): updated_current = force_parse_json_or_literal(updated_current) pos = _safe_pos(position) return ( updated_current, forest_scene(updated_current, collection, pos), game_status_markdown(updated_current, collection, pos), mushroom_card_from_state(updated_current), _normalize_history(updated_hist), ) def study_discovery(current, history, collection, position): legacy = _denormalize_history(history) # study_current() returns (observation_text, updated_history) โ the # mushroom itself doesn't change when studied, only the chat does. _observation, updated_hist = study_current(current, legacy) if isinstance(current, str): current = force_parse_json_or_literal(current) pos = _safe_pos(position) return ( current, forest_scene(current, collection, pos), game_status_markdown(current, collection, pos), mushroom_card_from_state(current), _normalize_history(updated_hist), ) def block_unsafe_eating(current, history): legacy = _denormalize_history(history) _, updated_hist = eat_current(current, None, legacy) return _normalize_history(updated_hist) def open_play_tab(): return gr.update(selected="play") def _safe_pos(position): if position is None: return START_POSITION x, y = position return (max(0,min(2,int(x))), max(0,min(2,int(y)))) # --------------------------------------------------------------------------- # Guide text # --------------------------------------------------------------------------- GUIDE_MARKDOWN = """ ## How to play 1. Go to **๐ฎ Play** and click **๐ฎ Search Clearing** to spawn mushrooms across the grid. 2. Use **โฌ๏ธโฌ๏ธโฌ ๏ธโก๏ธ** to move Myco โ walk over mushrooms to collect them automatically! 3. **Watch out** โ stepping on a poisonous mushroom triggers Game Over! 4. Myco also **roams autonomously** every 3 seconds collecting mushrooms for you. 5. Use the action buttons to Study, Collect, Pick, or Follow Whisper. 6. Ask Myco anything in the chat. **Scoring:** Common = 10 ยท Rare = 35 ยท Legendary = 100 ยท Poison = โ25 + Game Over **Poisonous:** Ghost Gill ยท Pepper Pixie ยท Ruby Knuckle ยท Clockwork Chanterelle """ # --------------------------------------------------------------------------- # App # --------------------------------------------------------------------------- def build_app(): with gr.Blocks(title="Myco โ Forest Adventure") as demo: gr.HTML("""""") # โโ Shared state โโ current_mushroom = gr.State(None) collection = gr.State([]) player_position = gr.State(START_POSITION) active_mushrooms = gr.State([]) gr.HTML(hero_markdown()) timer = gr.Timer(5) myco_bubble = gr.HTML() timer.tick( fn=lambda: bubble(get_myco_narrative()), outputs=myco_bubble ) # Autonomous roam timer โ every 5 seconds, pure Python movement roam_timer = gr.Timer(5) with gr.Tabs(selected="home") as tabs: with gr.Tab("๐ Home", id="home"): gr.HTML(home_forest_markdown()) start_play_button = gr.Button("๐ฒ Enter the Forest", variant="primary", size="lg") with gr.Tab("๐ฎ Play", id="play"): gr.HTML(game_intro_markdown()) scene = gr.HTML( forest_scene(None, EMPTY_COLLECTION, START_POSITION), elem_id="forest-scene", ) with gr.Row(): with gr.Column(scale=1): search_button = gr.Button("๐ฎ Search Clearing", variant="primary", size="lg") with gr.Column(scale=1): gr.Markdown("**Move Myco**") with gr.Row(): gr.HTML("
") north_button = gr.Button("โฌ๏ธ", size="sm") gr.HTML("") with gr.Row(): west_button = gr.Button("โฌ ๏ธ", size="sm") pick_button = gr.Button("๐ Pick", variant="primary", size="sm") east_button = gr.Button("โก๏ธ", size="sm") with gr.Row(): gr.HTML("") south_button = gr.Button("โฌ๏ธ", size="sm") gr.HTML("") status = gr.Markdown(game_status_markdown(None, EMPTY_COLLECTION, START_POSITION)) with gr.Row(equal_height=True, elem_id="discovery-card"): with gr.Column(scale=2): gr.Markdown("### ๐ Current Discovery") discovery = gr.Markdown(mushroom_card(None)) with gr.Row(): study_button = gr.Button("๐ Study", variant="secondary") collect_button = gr.Button("๐งบ Collect", variant="secondary") with gr.Row(): whisper_button = gr.Button("๐ Follow Whisper", variant="secondary") eat_button = gr.Button("๐ฝ๏ธ Eat?", variant="stop") with gr.Column(scale=3): gr.Markdown("### ๐ฌ Chat with Myco") chat = gr.Chatbot( value=cast(Any, _normalize_history(welcome_history())), label="Myco", height=400, show_label=False, ) with gr.Row(): prompt = gr.Textbox( placeholder="Ask Myco about this place...", show_label=False, scale=4, lines=1, ) ask_button = gr.Button("Ask ๐", variant="primary", scale=1) with gr.Tab("๐ MycoDex"): with gr.Row(): progress = gr.Markdown(progress_markdown(EMPTY_COLLECTION)) dex = gr.Markdown(dex_markdown(EMPTY_COLLECTION)) with gr.Group(elem_classes="myco-image-2"): gr.Image(value="assets/images/Myco-2.png", show_label=False, container=False, interactive=False) with gr.Tab("๐บ๏ธ Map"): world_map = gr.HTML(world_map_markdown(EMPTY_COLLECTION)) with gr.Tab("๐งญ Guide"): gr.Markdown(GUIDE_MARKDOWN) with gr.Group(elem_classes="myco-image"): gr.Image(value="assets/images/Myco.png", show_label=False, container=False, interactive=False) with gr.Tab("๐ Myco"): render_shroom_tab() # Streams Myco's full log: narrative ([NARRATIVE]), in-game action # reactions ([ACTION]), and chat replies ([CHAT]). myco_live = gr.Textbox( label="๐ Myco's live log (narrative / action / chat)", value="๐ Myco is listening...", lines=10, max_lines=20, autoscroll=True, interactive=False, elem_id="myco-live-log", ) # Poll the running Myco log (narrative/action/chat) once per second. # NOTE: previously this used demo.load(fn=_stream_myco, ...) with an # infinite `while True` generator. An infinite generator from # demo.load permanently occupies a queue worker/stream for that # session โ other events (button clicks) can then execute their # Python function fine (visible in server logs) but never get their # result delivered back to the browser. A periodic Timer avoids this. log_timer = gr.Timer(1) log_timer.tick(fn=get_myco_log, outputs=myco_live) # โโ Shared output lists โโ _move_outputs = [ player_position, scene, status, discovery, current_mushroom, collection, active_mushrooms, chat, dex, progress, ] # โโ Event wiring โโ start_play_button.click(open_play_tab, outputs=[tabs]) # Roam timer โ autonomous movement, pure Python roam_timer.tick( fn=_autonomous_tick, inputs=[current_mushroom, collection, active_mushrooms, player_position], outputs=_move_outputs, ) search_button.click( search_forest, inputs=[player_position, collection], outputs=[scene, status, discovery, current_mushroom, active_mushrooms, chat, dex, progress], show_progress="hidden", ) ask_button.click( ask_companion, inputs=[prompt, chat, current_mushroom, collection, player_position], outputs=[prompt, chat], show_progress="hidden", ) prompt.submit( ask_companion, inputs=[prompt, chat, current_mushroom, collection, player_position], outputs=[prompt, chat], show_progress="hidden", ) # Arrow buttons โ each hardcoded direction, correct inputs for direction, btn in [ ("north", north_button), ("south", south_button), ("west", west_button), ("east", east_button), ]: btn.click( lambda pos, col, cur, am, d=direction: move_player(pos, d, cur, col, am), inputs=[player_position, collection, current_mushroom, active_mushrooms], outputs=_move_outputs, show_progress="hidden", ) study_button.click( study_discovery, inputs=[current_mushroom, chat, collection, player_position], outputs=[current_mushroom, scene, status, discovery, chat], show_progress="hidden", ) collect_button.click( collect_discovery, inputs=[current_mushroom, collection, chat, player_position], outputs=[collection, scene, status, dex, world_map, chat, progress], show_progress="hidden", ) pick_button.click( pick_discovery, inputs=[current_mushroom, collection, chat, player_position], outputs=[ collection, current_mushroom, scene, status, discovery, dex, world_map, chat, progress, ], show_progress="hidden", ) whisper_button.click( follow_story_whisper, inputs=[current_mushroom, collection, chat, player_position], outputs=[current_mushroom, scene, status, discovery, chat], show_progress="hidden", ) eat_button.click( block_unsafe_eating, inputs=[current_mushroom, chat], outputs=[chat], show_progress="hidden", ) # Auto-spawn mushrooms when app first loads demo.load( fn=search_forest, inputs=[player_position, collection], outputs=[scene, status, discovery, current_mushroom, active_mushrooms, chat, dex, progress], ) return demo