Myco / ui /renderers.py
byte-vortex's picture
Deploy Myco from CI
e690d5e verified
Raw
History Blame Contribute Delete
22.4 kB
"""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"