Spaces:
Sleeping
Sleeping
| """Markdown renderers for the Myco Gradio interface.""" | |
| from html import escape | |
| from game.progression import ( | |
| AREA_UNLOCKS, current_area, next_area, next_mystery, | |
| revealed_mysteries, unlocked_areas, | |
| ) | |
| from game.state import CollectionState, EMPTY_DEX, MushroomState | |
| from models.mushroom import Mushroom | |
| import json | |
| import ast | |
| import re | |
| POISONOUS_MUSHROOM_NAMES = { | |
| "Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle", | |
| } | |
| def ensure_state(current): | |
| """ | |
| Safely converts input to a dictionary. | |
| If it's already a dict, return it. If a JSON string, parse it. | |
| If it's None or invalid, return an empty dict to prevent crashes. | |
| """ | |
| if isinstance(current, dict): | |
| return current | |
| if isinstance(current, str): | |
| try: | |
| return json.loads(current) | |
| except json.JSONDecodeError: | |
| return {} # Return empty dict if JSON is invalid | |
| return {} # Default for None or other types | |
| # --------------------------------------------------------------------------- | |
| # Static UI fragments | |
| # --------------------------------------------------------------------------- | |
| def hero_markdown() -> str: | |
| return """ | |
| <div class="myco-hero"> | |
| <div class="myco-mascot">🍄</div> | |
| <div> | |
| <p class="eyebrow">Tiny AI mushroom companion</p> | |
| <h1>Myco</h1> | |
| <p>Follow an AI mushroom companion through a glowing forest. Every discovery starts with Myco's intuition.</p> | |
| </div> | |
| </div> | |
| """ | |
| def bubble(narrative=None): | |
| if not narrative: | |
| return "" | |
| return f""" | |
| <div style=" | |
| font-size: 14px; | |
| padding: 12px; | |
| border-radius: 28px; | |
| background: linear-gradient(135deg, #1e1e1e 0%, #0f2027 50%, #203a43 100%); | |
| color: #b6ff9a; | |
| font-family: monospace; | |
| line-height: 1.4em; | |
| "> | |
| {narrative} 🍄 | |
| </div> | |
| """ | |
| def wrap(content): | |
| return f""" | |
| <div style=" | |
| font-size: 12px; | |
| padding: 12px; | |
| border-radius: 28px; | |
| background: linear-gradient(135deg, #1e1e1e 0%, #0f2027 50%, #203a43 100%); | |
| color: #b6ff9a; | |
| font-family: monospace; | |
| line-height: 1.4em; | |
| "> | |
| {content} 🍄 | |
| </div> | |
| """ | |
| def home_forest_markdown() -> str: | |
| return """ | |
| <div class="home-forest-postcard" aria-label="Animated Myco forest postcard"> | |
| <div class="home-sky"> | |
| <span class="home-milky-way">🌌</span> | |
| <span class="home-star star-one">✨</span> | |
| <span class="home-star star-two">✦</span> | |
| <span class="home-star star-three">✧</span> | |
| </div> | |
| <div class="home-forest-floor"> | |
| <span class="home-tree left">🌲</span> | |
| <span class="home-grass grass-left">🌿</span> | |
| <span class="home-myco-wrapper"> | |
| <span class="home-myco">🍄</span> | |
| <span class="home-spore" style="--x:-25px;--y:-45px;--duration:3.5s;--delay:0s;"></span> | |
| <span class="home-spore" style="--x:20px;--y:-35px;--duration:4.2s;--delay:0.8s;"></span> | |
| <span class="home-spore" style="--x:-5px;--y:-60px;--duration:3s;--delay:1.5s;"></span> | |
| </span> | |
| <span class="home-brown-wrapper"> | |
| <span class="home-brown-mushroom">🍄🟫</span> | |
| <span class="home-spore" style="--x:-15px;--y:-40px;--duration:3.8s;--delay:0.4s;background:#ffeaa7;box-shadow:0 0 6px #fdcb6e,0 0 10px #fff;"></span> | |
| <span class="home-spore" style="--x:25px;--y:-50px;--duration:4.5s;--delay:1.2s;background:#ffeaa7;box-shadow:0 0 6px #fdcb6e,0 0 10px #fff;"></span> | |
| </span> | |
| <span class="home-grass grass-right">🍃</span> | |
| <span class="home-tree right">🌳</span> | |
| <span class="home-moon">🌖</span> | |
| </div> | |
| <p><strong>The Milky Way is awake.</strong> Myco hears a brown mushroom whispering your first clue.</p> | |
| <span class="home-whisper">"Tap Play. Let's find what the forest is hiding."</span> | |
| </div> | |
| """ | |
| def play_hook_markdown() -> str: | |
| return """ | |
| <div class="play-hook"> | |
| <p class="eyebrow">Mission</p> | |
| <h2>Goal: follow clues until Myco remembers the Impossible Mushroom.</h2> | |
| <div class="hook-steps"> | |
| <span>1 · Move through the forest</span> | |
| <span>2 · Search Clearing — mushroom pops up!</span> | |
| <span>3 · Myco reacts · Pick, Study, or Follow Whisper</span> | |
| </div> | |
| <p>Every clue advances one mystery.</p> | |
| </div> | |
| """ | |
| def game_intro_markdown() -> str: | |
| return """ | |
| <div style="text-align:center;margin-bottom:.75rem;"> | |
| <h3>🌲 The Deep Forest</h3> | |
| <p style="color:#059605;font-size:.9em;"> | |
| You step into the glowing undergrowth. Myco hops off your shoulder, its cap faintly pulsing.<br> | |
| Click <strong>Search Clearing</strong> — watch Myco run toward whatever pops up. | |
| <span class="ai-tick-badge">🤖 Myco AI roaming</span> | |
| </p> | |
| </div> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Forest scene — accepts optional active_mushrooms list | |
| # --------------------------------------------------------------------------- | |
| def forest_scene( | |
| current: "MushroomState | None", | |
| collection: "CollectionState | None", | |
| position: "tuple[int,int] | None" = None, | |
| active_mushrooms: "list | None" = None, # ← NEW: list of mushroom dicts | |
| ) -> str: | |
| import time | |
| count = len(_safe_collection(collection)) | |
| area = current_area(count) | |
| pos_x, pos_y = _safe_position(position) | |
| pos_cls = f"pos-{pos_x}-{pos_y}" | |
| # Safe defaults — all variables set before any conditional | |
| mushroom_name = "Hidden mushroom" | |
| rarity = "common" | |
| sprite = "✨" | |
| status = "The moss is listening. Click Search Clearing to wake the forest." | |
| omen = "Myco is waiting to sense the first spore." | |
| event_label = "🌌 Dreaming Forest" | |
| encounter = "The next clearing may reveal a memory, creature, or poison omen." | |
| mystery_label = "The Wrong Memory" | |
| score_total = "0" | |
| health = "3" | |
| reward_text = "Search a clearing to score spores." | |
| state_cls = "state-waiting" | |
| current = ensure_state(current) | |
| if current is not None: | |
| mushroom_name = current.get("name", "Mystery cluster") | |
| rarity = current.get("rarity", "Common").lower() | |
| game_over = current.get("game_over") == "Yes" | |
| event_label = f"{current.get('event_emoji','✨')} {current.get('event_title','Strange Omen')}" | |
| encounter = current.get("encounter", "Go pick them up!") | |
| mystery_label = current.get("mystery_title", "The Wrong Memory") | |
| score_total = current.get("score_total", "0") | |
| health = current.get("health", "3") | |
| reward_text = current.get("reward_text", "Dodge the poison, collect the rest!") | |
| state_cls = _scene_state_class(current) | |
| if game_over: | |
| status = "💀 GAME OVER: You stepped on a poisonous mushroom!" | |
| omen = "Press Search Clearing to start a new run." | |
| elif current.get("picked") == "Yes": | |
| status = "✅ Area clear! Move to the next clearing." | |
| omen = "Search the next clearing!" | |
| else: | |
| status = f"A {rarity} {mushroom_name} breaks through the moss!" | |
| omen = "🍄 Study it before picking!" | |
| # Build roaming mushroom sprites from active_mushrooms | |
| active_sprites = _build_active_sprites(active_mushrooms or []) | |
| anim_key = str(int(time.time() * 1000))[-6:] | |
| return f''' | |
| <div class="forest-scene rarity-{escape(rarity)} {pos_cls} {state_cls}" data-key="{anim_key}"> | |
| <div class="scene-hud"> | |
| <span>🧭 {escape(area.name)}</span> | |
| <span>🎮 ({pos_x+1},{pos_y+1})</span> | |
| <span>📖 {count} found</span> | |
| <span>⭐ {escape(str(score_total))}</span> | |
| <span>❤️ {escape(str(health))}/3</span> | |
| <span>{escape(event_label)}</span> | |
| <span>🧩 {escape(mystery_label)}</span> | |
| </div> | |
| <div class="scene-stage" aria-label="Magical forest scene"> | |
| <span class="tree tree-left">🌲</span> | |
| <span class="tree tree-right">🌲</span> | |
| <span class="grass grass-one">🌿</span> | |
| <span class="grass grass-two">🍃</span> | |
| <span class="myco-player" title="Myco" style="left:{["14%","41%","67%"][pos_x]};bottom:{["62%","36%","13%"][pos_y]};">🍄</span> | |
| <span class="mushroom-sprite" title="{escape(mushroom_name)}">{escape(sprite)}</span> | |
| {active_sprites} | |
| <span class="sparkle sparkle-one">✨</span> | |
| <span class="sparkle sparkle-two">✦</span> | |
| <span class="wood-log">🪵</span> | |
| <span class="fallen-leaf">🍂</span> | |
| </div> | |
| <div class="forest-map" aria-label="Forest grid"> | |
| {_movement_tiles(pos_x, pos_y, active_mushrooms)} | |
| </div> | |
| <div class="scene-caption"> | |
| <strong>{escape(str(status))}</strong> | |
| <span>{escape(str(omen))}</span> | |
| </div> | |
| <div class="score-burst" aria-live="polite">{escape(str(reward_text))}</div> | |
| <div class="story-moment"> | |
| <strong>{escape(str(event_label))}</strong> | |
| <span>{escape(str(encounter))}</span> | |
| </div> | |
| </div>''' | |
| # --------------------------------------------------------------------------- | |
| # Active mushroom sprites builder | |
| # --------------------------------------------------------------------------- | |
| def _build_active_sprites(active_mushrooms: list) -> str: | |
| """Render roaming mushrooms with inline styles so position works inside Gradio components.""" | |
| # Grid positions: x=0->10%, x=1->37%, x=2->63% / y=0->58%, y=1->32%, y=2->9% | |
| LEFT = ["10%", "37%", "63%"] | |
| BOTTOM= ["58%", "32%", "9%"] | |
| sprites = [] | |
| for m in active_mushrooms: | |
| if not isinstance(m, dict): | |
| continue | |
| if m.get("picked") == "Yes": | |
| continue | |
| mx = max(0, min(2, int(m.get("mush_x", 1)))) | |
| my = max(0, min(2, int(m.get("mush_y", 1)))) | |
| is_poison = m.get("poison") == "Yes" | |
| rarity = m.get("rarity", "Common") | |
| if is_poison: | |
| icon = "☠️" | |
| color = "rgba(255,62,91,1)" | |
| extra = "animation:roam-bob 2s ease-in-out infinite,poison-pulse 1.2s ease-in-out infinite;" | |
| elif rarity == "Legendary": | |
| icon = "🍄🟫" | |
| color = "rgba(255,215,0,1)" | |
| extra = "animation:roam-bob 2s ease-in-out infinite,legendary-pulse 1s ease-in-out infinite;" | |
| elif rarity == "Rare": | |
| icon = "🍄" | |
| color = "rgba(138,220,255,1)" | |
| extra = "animation:roam-bob 3s ease-in-out infinite;" | |
| else: | |
| icon = "🍄🟫" | |
| color = "rgba(212,255,0,1)" | |
| extra = "animation: roam-bob 3s ease-in-out infinite; transform-origin: center; filter: drop-shadow(0 0 20px #d4ff00);"; | |
| name = escape(str(m.get("name", "?"))) | |
| style = ( | |
| f"position:absolute;font-size:2.4rem;z-index:2;line-height:1;" | |
| f"left:{LEFT[mx]};bottom:{BOTTOM[my]};" | |
| f"filter:drop-shadow(0 0 8px {color});" | |
| f"transition:left .3s,bottom .3s;{extra}" | |
| ) | |
| sprites.append(f'<span style="{style}" title="{name}">{icon}</span>') | |
| return "\n ".join(sprites) | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _safe_position(position): | |
| if position is None: | |
| return (1, 1) | |
| try: | |
| x, y = position | |
| return (max(0, min(2, int(x))), max(0, min(2, int(y)))) | |
| except Exception: | |
| return (1, 1) | |
| def _movement_tiles(active_x, active_y, active_mushrooms=None): | |
| """Grid map tiles — marks player, mushrooms, and poison cells.""" | |
| mush_coords = {} | |
| for m in (active_mushrooms or []): | |
| if not isinstance(m, dict): | |
| continue | |
| if m.get("picked") == "Yes": | |
| continue | |
| mx, my = int(m.get("mush_x", -1)), int(m.get("mush_y", -1)) | |
| if 0 <= mx <= 2 and 0 <= my <= 2: | |
| mush_coords[(mx, my)] = m | |
| tiles = [] | |
| for y in range(3): | |
| for x in range(3): | |
| is_player = (x, y) == (int(active_x), int(active_y)) | |
| mush = mush_coords.get((x, y)) | |
| if is_player: | |
| css = "map-tile active" | |
| icon = "🍄" | |
| elif mush: | |
| is_p = mush.get("poison") == "Yes" | |
| css = "map-tile has-poison" if is_p else "map-tile has-mush" | |
| icon = "☠️" if is_p else "✨" | |
| else: | |
| css = "map-tile" | |
| icon = "·" | |
| tiles.append(f'<span class="{css}">{icon}</span>') | |
| return "".join(tiles) | |
| def _scene_state_class(current): | |
| if not current: | |
| return "state-waiting" | |
| if current.get("game_over") == "Yes": | |
| return "state-game-over" | |
| if current.get("picked") == "Yes": | |
| return "state-picked" | |
| if current.get("poison") == "Yes": | |
| return "state-danger" | |
| return "state-discovered" | |
| def _safe_collection(collection): | |
| """Return a list of dicts, parsing any string entries that Gradio may have serialized.""" | |
| import json as _json | |
| result = [] | |
| for entry in list(collection or []): | |
| if isinstance(entry, str): | |
| try: | |
| entry = _json.loads(entry) | |
| except Exception: | |
| continue | |
| if isinstance(entry, dict): | |
| result.append(entry) | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Game status panel | |
| # --------------------------------------------------------------------------- | |
| def game_status_markdown( | |
| current: "MushroomState | None", | |
| collection: "CollectionState | None", | |
| position: "tuple[int,int] | None" = None, | |
| ) -> str: | |
| count = len(_safe_collection(collection)) | |
| pos_x, pos_y = _safe_position(position) | |
| active_mystery = next_mystery(count) | |
| mystery_line = _mystery_goal_line(active_mystery, count) | |
| current = ensure_state(current) | |
| if current is None: | |
| objective = "Goal: solve **one mystery** — why does Myco remember a vanished forest?" | |
| discovery = "No active discovery yet. Move Myco, then Search Clearing." | |
| ai_job = "Myco AI is roaming — watch it search, study, and collect automatically." | |
| event = "**Event:** waiting for the first omen" | |
| clue_line = mystery_line | |
| thread_line = "**Mystery thread:** The Wrong Memory — search the first clearing." | |
| bloom_line = f"**Impossible bloom:** {_mystery_bloom_meter(count)}" | |
| score_line = "**Score:** 0 spores · **Health:** 3/3" | |
| else: | |
| name = current.get("name", "Mystery mushroom") | |
| studied = current.get("studied") == "Yes" | |
| game_over = current.get("game_over") == "Yes" | |
| picked = current.get("picked") == "Yes" | |
| if game_over: | |
| objective = "**💀 GAME OVER** — press **Search Clearing** to start a new run." | |
| elif picked: | |
| objective = "Current objective: **move to the next clearing and search again**." | |
| else: | |
| next_step = "Collect it to preserve the clue" if studied else "Study it, then Collect or Pick" | |
| objective = f"Current objective: **{next_step}**." | |
| bloom_line = f"**Impossible bloom:** {_mystery_bloom_meter(count)}" | |
| score_line = ( | |
| f"**Score:** {current.get('score_total','0')} spores · " | |
| f"**Health:** {current.get('health','3')}/3 · " | |
| f"**Last:** {current.get('reward_text','—')}" | |
| ) | |
| discovery = f"Active discovery: **{name}** · Rarity: **{current.get('rarity','Unknown')}**" | |
| event = f"**Event:** {current.get('event_emoji','✨')} {current.get('event_title','Strange Omen')}" | |
| clue_line = f"**Clue:** {current.get('clue','Myco is still listening.')}" | |
| thread_line = ( | |
| f"**Mystery thread:** {current.get('mystery_title','The Wrong Memory')} — " | |
| f"Next: {current.get('mystery_next','follow the whisper.')}" | |
| ) | |
| ai_job = "Myco AI is watching — it will study and collect automatically while you explore." | |
| return "\n".join(( | |
| "### 🎮 Quest Log", | |
| f"**MycoDex:** {count} discoveries · **Map:** ({pos_x+1},{pos_y+1})", | |
| discovery, objective, score_line, bloom_line, | |
| event, clue_line, thread_line, mystery_line, ai_job, | |
| )) | |
| # --------------------------------------------------------------------------- | |
| # Mushroom card | |
| # --------------------------------------------------------------------------- | |
| def mushroom_card( | |
| mushroom: "Mushroom | None", | |
| clue: "str | None" = None, | |
| secret: "str | None" = None, | |
| state: "MushroomState | None" = None, | |
| ) -> str: | |
| if mushroom is None: | |
| return "### 🌲 Forest Clearing\nClick **Search Clearing** — watch Myco run toward the mushroom!" | |
| s = state or {} | |
| poison = mushroom.name in POISONOUS_MUSHROOM_NAMES | |
| warning = "\n> ⚠️ **Myco senses danger — do NOT pick this without studying first!**" if poison else "" | |
| return "\n".join(( | |
| f"### 🍄🟫 {mushroom.name}", | |
| f"**{mushroom.rarity} discovery** · found near **{mushroom.habitat}**", | |
| f"> {mushroom.lore}", | |
| warning, "", | |
| f"* **Edible:** {mushroom.edible}", | |
| f"* **Magic:** {mushroom.magic}", | |
| f"* **Danger:** {mushroom.danger}", | |
| f"* **Value:** {_mushroom_value_label(mushroom.rarity)}", | |
| f"* **Poison risk:** {_poison_risk_label(mushroom.name)}", | |
| f"* **Event:** {s.get('event_emoji','✨')} {s.get('event_title','Strange Omen')}", | |
| f"* **Mystery:** {s.get('mystery_title','The Wrong Memory')}", | |
| f"* **Clue:** {clue or 'Study with Myco to reveal the clue.'}", | |
| f"* **Secret:** {secret or 'Collect it to see how the MycoDex reacts.'}", | |
| f"* **Status:** {'⚠️ POISONOUS' if poison else '✅ Appears safe'}", | |
| )) | |
| def _mushroom_value_label(rarity): | |
| return {"Legendary": "100 spores", "Rare": "35 spores"}.get(rarity, "10 spores") | |
| def _poison_risk_label(name): | |
| return "⚠️ POISONOUS — Game Over if picked!" if name in POISONOUS_MUSHROOM_NAMES else "No obvious poison" | |
| # --------------------------------------------------------------------------- | |
| # World map / Dex / Progress | |
| # --------------------------------------------------------------------------- | |
| def world_map_markdown(collection: "CollectionState | None") -> str: | |
| count = len(_safe_collection(collection)) | |
| cards = [] | |
| for area in AREA_UNLOCKS: | |
| unlocked = count >= area.required_discoveries | |
| state = "unlocked" if unlocked else "locked" | |
| badge = "✅ Unlocked" if unlocked else f"🔒 {area.required_discoveries - count} away" | |
| cards.append("\n".join(( | |
| f'<div class="map-card {state}">', | |
| ' <div class="map-card-top">', | |
| f' <strong>{escape(area.name)}</strong><span>{badge}</span>', | |
| ' </div>', | |
| f' <p>{escape(area.description)}</p>', | |
| '</div>', | |
| ))) | |
| secrets = tuple(_secret_badge(t.name, count >= t.required_discoveries) for t in revealed_mysteries(count)) | |
| if not secrets: | |
| secrets = (_secret_badge("First Spore", False),) | |
| return "\n".join(( | |
| '<div class="world-map-panel">', | |
| ' <div class="world-map-header"><p class="eyebrow">World Map</p>', | |
| ' <h2>Forest Atlas</h2>', | |
| f' <p>{count} discoveries logged.</p></div>', | |
| f' <div class="achievement-row">{"".join(secrets)}</div>', | |
| f' <div class="map-grid">{"".join(cards)}</div>', | |
| '</div>', | |
| )) | |
| def _secret_badge(label, unlocked): | |
| state = "earned" if unlocked else "pending" | |
| icon = "🔎" if unlocked else "◇" | |
| return f'<span class="achievement {state}">{icon} {escape(label)}</span>' | |
| def dex_markdown(collection: "CollectionState | None") -> str: | |
| current_collection = _safe_collection(collection) | |
| if not current_collection: | |
| return EMPTY_DEX | |
| rows = ["### 📖 MycoDex", ""] | |
| for entry in current_collection: | |
| # Entries can arrive as JSON strings if Gradio serialized the state | |
| if isinstance(entry, str): | |
| try: | |
| import json as _json | |
| entry = _json.loads(entry) | |
| except Exception: | |
| continue | |
| if not isinstance(entry, dict): | |
| continue | |
| poison_flag = " ☠️" if entry.get("poison") == "Yes" else "" | |
| rows.append( | |
| f"* 🍄 **{entry.get('name','Unknown')}**{poison_flag} — " | |
| f"{entry.get('rarity','Unknown')} — " | |
| f"{entry.get('habitat','Unknown habitat')} — clue: {entry.get('clue','not studied yet')}" | |
| ) | |
| return "\n".join(rows) | |
| def progress_markdown(collection: "CollectionState | None") -> str: | |
| count = len(_safe_collection(collection)) | |
| active_area = current_area(count) | |
| upcoming_area = next_area(count) | |
| unlocked_names = ", ".join(a.name for a in unlocked_areas(count)) | |
| rows = [ | |
| "### ✨ Forest Progress", | |
| f"**MycoDex discoveries:** {count}", | |
| f"**Current area:** {active_area.name} — {active_area.description}", | |
| f"**Unlocked:** {unlocked_names}", | |
| ] | |
| upcoming_mystery = next_mystery(count) | |
| if upcoming_area: | |
| rows.append(f"**Next area:** {upcoming_area.required_discoveries - count} more to unlock **{upcoming_area.name}**.") | |
| else: | |
| rows.append(f"**All areas visible.** You reached **{AREA_UNLOCKS[-1].name}**.") | |
| if upcoming_mystery: | |
| rows.append(f"**Next secret:** {upcoming_mystery.required_discoveries - count} more to reveal **{upcoming_mystery.name}**.") | |
| else: | |
| rows.append("**All current secrets found.**") | |
| return "\n\n".join(rows) | |
| def _mystery_goal_line(active_mystery, count): | |
| if active_mystery is None: | |
| return "**Mystery:** Every known clue is awake. Keep exploring for rare surprises." | |
| remaining = active_mystery.required_discoveries - count | |
| return f"**Mystery:** {remaining} clue discoveries until **{active_mystery.name}** — {active_mystery.clue}" | |
| def _mystery_bloom_meter(count): | |
| filled = max(0, min(5, count)) | |
| petals = "".join("🌕" if i < filled else "🌑" for i in range(5)) | |
| if filled >= 5: | |
| return f"{petals} Impossible Bloom visible" | |
| return f"{petals} {5 - filled} clue-whispers remain" | |