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