"""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 """
๐Ÿ„

Tiny AI mushroom companion

Myco

Follow an AI mushroom companion through a glowing forest. Every discovery starts with Myco's intuition.

""" def bubble(narrative=None): if not narrative: return "" return f"""
{narrative} ๐Ÿ„
""" def wrap(content): return f"""
{content} ๐Ÿ„
""" def home_forest_markdown() -> str: return """
๐ŸŒŒ โœจ โœฆ โœง
๐ŸŒฒ ๐ŸŒฟ ๐Ÿ„ ๐Ÿ„โ€๐ŸŸซ ๐Ÿƒ ๐ŸŒณ ๐ŸŒ–

The Milky Way is awake. Myco hears a brown mushroom whispering your first clue.

"Tap Play. Let's find what the forest is hiding."
""" def play_hook_markdown() -> str: return """

Mission

Goal: follow clues until Myco remembers the Impossible Mushroom.

1 ยท Move through the forest 2 ยท Search Clearing โ€” mushroom pops up! 3 ยท Myco reacts ยท Pick, Study, or Follow Whisper

Every clue advances one mystery.

""" def game_intro_markdown() -> str: return """

๐ŸŒฒ The Deep Forest

You step into the glowing undergrowth. Myco hops off your shoulder, its cap faintly pulsing.
Click Search Clearing โ€” watch Myco run toward whatever pops up. ๐Ÿค– Myco AI roaming

""" # --------------------------------------------------------------------------- # 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'''
๐Ÿงญ {escape(area.name)} ๐ŸŽฎ ({pos_x+1},{pos_y+1}) ๐Ÿ“– {count} found โญ {escape(str(score_total))} โค๏ธ {escape(str(health))}/3 {escape(event_label)} ๐Ÿงฉ {escape(mystery_label)}
๐ŸŒฒ ๐ŸŒฒ ๐ŸŒฟ ๐Ÿƒ ๐Ÿ„ {escape(sprite)} {active_sprites} โœจ โœฆ ๐Ÿชต ๐Ÿ‚
{_movement_tiles(pos_x, pos_y, active_mushrooms)}
{escape(str(status))} {escape(str(omen))}
{escape(str(reward_text))}
{escape(str(event_label))} {escape(str(encounter))}
''' # --------------------------------------------------------------------------- # 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'{icon}') 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'{icon}') 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'
', '
', f' {escape(area.name)}{badge}', '
', f'

{escape(area.description)}

', '
', ))) 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(( '
', '

World Map

', '

Forest Atlas

', f'

{count} discoveries logged.

', f'
{"".join(secrets)}
', f'
{"".join(cards)}
', '
', )) def _secret_badge(label, unlocked): state = "earned" if unlocked else "pending" icon = "๐Ÿ”Ž" if unlocked else "โ—‡" return f'{icon} {escape(label)}' 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"