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 | |
| POISONOUS_MUSHROOM_NAMES = { | |
| "Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle", | |
| } | |
| # ── Inline CSS + keyframes injected once into the scene ────────────────────── | |
| SCENE_STYLES = """ | |
| <style> | |
| /* ── Base scene ── */ | |
| .forest-scene { | |
| position: relative; isolation: isolate; | |
| overflow: hidden; min-height: 20rem; | |
| padding: 0.75rem; border-radius: 28px; | |
| background: | |
| radial-gradient(circle at 50% 72%, rgba(255,239,173,.26), transparent 8rem), | |
| radial-gradient(circle at 18% 24%, rgba(134,225,153,.22), transparent 10rem), | |
| linear-gradient(180deg,#123626 0%,#245c3c 54%,#523b20 100%); | |
| border: 1px solid rgba(225,255,209,.25); | |
| box-shadow: 0 18px 48px rgba(8,30,20,.26); | |
| color: #f9ffe9; | |
| } | |
| /* ── HUD bar ── */ | |
| .scene-hud { | |
| position: relative; z-index: 3; | |
| display: flex; flex-wrap: wrap; gap: .4rem; | |
| padding: .5rem .75rem; border-radius: 999px; | |
| background: rgba(13,38,28,.72); backdrop-filter: blur(6px); | |
| font-size: .8rem; font-weight: 800; | |
| } | |
| /* ── Stage ── */ | |
| .scene-stage { | |
| position: relative; height: 13rem; | |
| margin: .6rem 0; | |
| background: | |
| linear-gradient(transparent 62%, rgba(106,79,33,.55) 63%), | |
| radial-gradient(ellipse at bottom, rgba(190,255,180,.2), transparent 60%); | |
| border-radius: 22px; overflow: hidden; | |
| } | |
| .scene-stage span { position: absolute; line-height:1; } | |
| /* ── Trees / grass ── */ | |
| .tree-left { left:6%; bottom:18%; font-size:5rem; filter:drop-shadow(0 8px 12px rgba(0,0,0,.28)); } | |
| .tree-right { right:7%; bottom:20%; font-size:4.75rem; filter:drop-shadow(0 8px 12px rgba(0,0,0,.28)); } | |
| .grass-one { left:26%; bottom:14%; font-size:2.4rem; } | |
| .grass-two { right:28%;bottom:16%; font-size:2.2rem; } | |
| /* ── Sparkles ── */ | |
| .sparkle-one { left:34%; top:18%; font-size:1.8rem; animation:twinkle 1.8s ease-in-out infinite; } | |
| .sparkle-two { right:33%; top:24%; font-size:1.6rem; animation:twinkle 2.2s ease-in-out infinite reverse; } | |
| @keyframes twinkle { | |
| 0%,100%{opacity:.3;transform:scale(.85);} | |
| 50%{opacity:1;transform:scale(1.18);} | |
| } | |
| /* ── Myco player — position grid ── */ | |
| .myco-player { | |
| font-size:4rem; z-index:2; | |
| transition: left .32s cubic-bezier(.34,1.56,.64,1), bottom .32s cubic-bezier(.34,1.56,.64,1); | |
| animation: myco-bob 2.4s ease-in-out infinite; | |
| filter:drop-shadow(0 6px 14px rgba(255,92,92,.28)); | |
| } | |
| @keyframes myco-bob { | |
| 0%,100%{transform:translateY(0);} | |
| 50%{transform:translateY(-.45rem);} | |
| } | |
| .pos-0-0 .myco-player{left:14%;bottom:62%;} | |
| .pos-1-0 .myco-player{left:41%;bottom:62%;} | |
| .pos-2-0 .myco-player{left:67%;bottom:62%;} | |
| .pos-0-1 .myco-player{left:14%;bottom:36%;} | |
| .pos-1-1 .myco-player{left:41%;bottom:36%;} | |
| .pos-2-1 .myco-player{left:67%;bottom:36%;} | |
| .pos-0-2 .myco-player{left:14%;bottom:13%;} | |
| .pos-1-2 .myco-player{left:41%;bottom:13%;} | |
| .pos-2-2 .myco-player{left:67%;bottom:13%;} | |
| /* ── Mushroom sprite ── */ | |
| .mushroom-sprite { | |
| right:33%; bottom:22%; font-size:3.6rem; z-index:2; | |
| filter:drop-shadow(0 6px 14px rgba(0,0,0,.3)); | |
| } | |
| /* ── STATE: waiting (no mushroom) ── */ | |
| .state-waiting .mushroom-sprite { opacity:0; } | |
| /* ── STATE: discovered — mushroom pops up, Myco runs toward it ── */ | |
| .state-discovered .mushroom-sprite { | |
| animation: shroom-popup 0.7s cubic-bezier(.34,1.56,.64,1) both; | |
| } | |
| .state-discovered .myco-player { | |
| animation: myco-bob 2.4s ease-in-out infinite, | |
| myco-run-in 0.6s cubic-bezier(.34,1.56,.64,1) both; | |
| } | |
| @keyframes shroom-popup { | |
| 0% { opacity:0; transform:translateY(3rem) scale(.2); } | |
| 60% { opacity:1; transform:translateY(-.4rem) scale(1.18); } | |
| 100% { opacity:1; transform:translateY(0) scale(1); } | |
| } | |
| @keyframes myco-run-in { | |
| 0% { transform:translateX(-3rem) scaleX(-1); } | |
| 100% { transform:translateX(0) scaleX(1); } | |
| } | |
| /* ── STATE: picked — mushroom flies up and disappears ── */ | |
| .state-picked .mushroom-sprite { | |
| animation: shroom-catch 0.55s cubic-bezier(.34,1.56,.64,1) both; | |
| } | |
| .state-picked .myco-player { | |
| animation: myco-bob 2.4s ease-in-out infinite, | |
| myco-catch-jump 0.55s ease both; | |
| } | |
| @keyframes shroom-catch { | |
| 0% { opacity:1; transform:translateY(0) scale(1); } | |
| 40% { opacity:1; transform:translateY(-2.5rem) scale(1.3) rotate(15deg); } | |
| 100% { opacity:0; transform:translateY(-5rem) scale(.2) rotate(40deg); } | |
| } | |
| @keyframes myco-catch-jump { | |
| 0%,100%{ transform:translateY(0); } | |
| 40% { transform:translateY(-1.4rem) scale(1.1); } | |
| } | |
| /* ── STATE: danger (poison, not yet picked) ── */ | |
| .state-danger .mushroom-sprite { | |
| animation: shroom-popup 0.7s cubic-bezier(.34,1.56,.64,1) both, | |
| poison-pulse 1.2s ease-in-out infinite 0.7s; | |
| filter: drop-shadow(0 0 18px rgba(255,62,91,.9)); | |
| } | |
| .state-danger .myco-player { | |
| animation: myco-bob 2.4s ease-in-out infinite, | |
| myco-recoil 0.5s ease both; | |
| } | |
| @keyframes poison-pulse { | |
| 0%,100%{ filter:drop-shadow(0 0 10px rgba(255,62,91,.6)); } | |
| 50% { filter:drop-shadow(0 0 28px rgba(255,62,91,1)); } | |
| } | |
| @keyframes myco-recoil { | |
| 0% { transform:translateX(0); } | |
| 30% { transform:translateX(-1.2rem); } | |
| 60% { transform:translateX(.4rem); } | |
| 100%{ transform:translateX(0); } | |
| } | |
| /* ── STATE: game over ── */ | |
| .state-game-over .forest-scene, | |
| .state-game-over { | |
| animation: screen-shake .45s ease both; | |
| background: | |
| radial-gradient(circle at 50% 72%, rgba(255,62,91,.35), transparent 8rem), | |
| linear-gradient(180deg,#3b0a0a 0%,#6b1a1a 54%,#2a0a0a 100%) !important; | |
| } | |
| .state-game-over .mushroom-sprite { | |
| animation: shroom-explode .6s ease both; | |
| filter: drop-shadow(0 0 24px rgba(255,62,91,1)); | |
| } | |
| .state-game-over .myco-player { | |
| animation: myco-fall .6s ease both; | |
| } | |
| @keyframes screen-shake { | |
| 0%,100%{ transform:translateX(0); } | |
| 20% { transform:translateX(-8px); } | |
| 40% { transform:translateX(8px); } | |
| 60% { transform:translateX(-5px); } | |
| 80% { transform:translateX(5px); } | |
| } | |
| @keyframes shroom-explode { | |
| 0% { transform:scale(1) rotate(0); opacity:1; } | |
| 50% { transform:scale(1.8) rotate(20deg); opacity:.8; } | |
| 100%{ transform:scale(0) rotate(90deg); opacity:0; } | |
| } | |
| @keyframes myco-fall { | |
| 0% { transform:rotate(0) translateY(0); opacity:1; } | |
| 100%{ transform:rotate(-90deg) translateY(2rem); opacity:.4; } | |
| } | |
| /* ── Rarity glows ── */ | |
| .rarity-rare .mushroom-sprite { filter:drop-shadow(0 0 18px rgba(138,220,255,.9)); } | |
| .rarity-legendary .mushroom-sprite{ filter:drop-shadow(0 0 28px rgba(255,215,0,1)); animation:legendary-pulse 1s ease-in-out infinite 0.7s; } | |
| @keyframes legendary-pulse { | |
| 0%,100%{ filter:drop-shadow(0 0 18px rgba(255,215,0,.7)); transform:scale(1); } | |
| 50% { filter:drop-shadow(0 0 36px rgba(255,215,0,1)); transform:scale(1.1); } | |
| } | |
| /* ── Mini map grid ── */ | |
| .forest-map { | |
| display:grid; grid-template-columns:repeat(3,1fr); | |
| gap:.3rem; margin:.4rem auto .6rem; | |
| max-width:9rem; padding:.4rem; | |
| border:1px solid rgba(255,255,255,.18); border-radius:14px; | |
| background:rgba(13,38,28,.58); | |
| } | |
| .map-tile { | |
| display:grid; min-height:1.7rem; place-items:center; | |
| border-radius:8px; background:rgba(255,255,255,.1); font-weight:900; | |
| } | |
| .map-tile.active { background:rgba(255,224,138,.92); color:#3d2c00!important; } | |
| /* ── Caption ── */ | |
| .scene-caption { | |
| display:flex; justify-content:space-between; gap:.6rem; | |
| padding:.55rem .8rem; border-radius:14px; | |
| background:rgba(13,38,28,.68); backdrop-filter:blur(6px); | |
| align-items:center; | |
| } | |
| .scene-caption span { color:#d9f4d4; } | |
| /* ── Score burst ── */ | |
| .score-burst { | |
| margin:.65rem auto 0; max-width:520px; | |
| padding:.5rem .85rem; border-radius:999px; | |
| background:rgba(255,224,112,.18); border:1px solid rgba(255,244,190,.36); | |
| color:#fff7d6; font-weight:900; text-align:center; | |
| animation:score-pop .56s ease both; | |
| } | |
| @keyframes score-pop { | |
| 0%{transform:scale(.92);opacity:0;} | |
| 45%{transform:scale(1.06);opacity:1;} | |
| 100%{transform:scale(1);opacity:1;} | |
| } | |
| .state-game-over .score-burst { | |
| background:rgba(130,0,30,.45); border-color:rgba(255,120,140,.55); | |
| } | |
| /* ── Story moment ── */ | |
| .story-moment { | |
| margin:.75rem auto 0; max-width:760px; | |
| padding:.8rem 1rem; border:1px solid rgba(255,234,166,.3); | |
| border-radius:18px; background:rgba(25,18,45,.72); | |
| color:#fff7d6; box-shadow:0 0 24px rgba(165,111,255,.22); | |
| } | |
| .story-moment strong,.story-moment span { display:block; } | |
| .story-moment span { margin-top:.2rem; color:#f6e7b2; } | |
| /* ── Suppress Gradio white flash ── */ | |
| .generating .forest-scene, .pending .forest-scene { opacity:.88!important; } | |
| .progress-level,.progress-level-inner { display:none!important; } | |
| div.gradio-html { background:transparent!important; } | |
| .wrap.default.pending,.wrap.default.generating { background:transparent!important; } | |
| </style> | |
| """ | |
| def hero_markdown() -> str: | |
| return """ | |
| <div class="myco-hero"> | |
| <div class="myco-mascot">🍄</div> | |
| <div> | |
| <p class="eyebrow">Tiny AI forest game</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 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" title="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" title="Myco">🍄</span> | |
| <span class="home-brown-mushroom" title="Brown mushroom">🍄🟫</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. Follow whispers to reveal why the MycoDex is regrowing a forest that vanished.</p> | |
| </div> | |
| """ | |
| def forest_scene( | |
| current: MushroomState | None, | |
| collection: CollectionState | None, | |
| position: tuple[int, int] | None = None, | |
| ) -> str: | |
| count = len(_safe_collection(collection)) | |
| area = current_area(count) | |
| pos_x, pos_y = _safe_position(position) | |
| pos_cls = f"pos-{pos_x}-{pos_y}" | |
| if current is None: | |
| 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" | |
| else: | |
| mushroom_name = current.get("name", "Mystery mushroom") | |
| rarity = current.get("rarity", "Common").lower() | |
| sprite = "🍄🟫" | |
| game_over = current.get("game_over") == "Yes" | |
| studied = current.get("studied") == "Yes" | |
| event_label = f"{current.get('event_emoji','✨')} {current.get('event_title','Strange Omen')}" | |
| encounter = current.get("encounter", "The forest waits for Myco's reaction.") | |
| 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", "Decide whether to risk the pickup.") | |
| state_cls = _scene_state_class(current) | |
| status = f"A {rarity} {mushroom_name} breaks through the moss!" | |
| if game_over: | |
| status = f"💀 GAME OVER: {mushroom_name} was poisonous!" | |
| omen = "New field note unlocked." if studied else "Ask Myco what the forest is hiding." | |
| if current.get("picked") == "Yes": | |
| omen = "✅ Picked! Move to the next clearing." | |
| if game_over: | |
| omen = "Press Search Clearing to start a new run." | |
| # Unique key forces browser to re-run CSS animations on every render | |
| import time | |
| anim_key = str(int(time.time() * 1000))[-6:] | |
| return f'''{SCENE_STYLES} | |
| <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(score_total)}</span> | |
| <span>❤️ {escape(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">🍄</span> | |
| <span class="mushroom-sprite" title="{escape(mushroom_name)}">{sprite if current else "✨"}</span> | |
| <span class="sparkle sparkle-one">✨</span> | |
| <span class="sparkle sparkle-two">✦</span> | |
| </div> | |
| <div class="forest-map" aria-label="Forest grid"> | |
| {_movement_tiles(pos_x, pos_y)} | |
| </div> | |
| <div class="scene-caption"> | |
| <strong>{escape(status)}</strong> | |
| <span>{escape(omen)}</span> | |
| </div> | |
| <div class="score-burst" aria-live="polite">{escape(reward_text)}</div> | |
| <div class="story-moment"> | |
| <strong>{escape(event_label)}</strong> | |
| <span>{escape(encounter)}</span> | |
| </div> | |
| </div> | |
| ''' | |
| def _safe_position(position): | |
| if position is None: | |
| return (1, 1) | |
| x, y = position | |
| return (max(0, min(2, int(x))), max(0, min(2, int(y)))) | |
| def _movement_tiles(active_x, active_y): | |
| tiles = [] | |
| for y in range(3): | |
| for x in range(3): | |
| active = " active" if (x, y) == (active_x, active_y) else "" | |
| icon = "🍄" if active else "·" | |
| tiles.append(f'<span class="map-tile{active}">{icon}</span>') | |
| return "".join(tiles) | |
| def _scene_state_class(current): | |
| 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 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) | |
| 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's job: notice clues you would miss alone." | |
| 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" | |
| next_step= "Collect it to preserve the clue" if studied else "Ask Myco, then Study or Follow Whisper" | |
| if current.get("game_over") == "Yes": | |
| objective = "**💀 GAME OVER** — press **Search Clearing** to start a new run." | |
| elif current.get("picked") == "Yes": | |
| objective = "Current objective: **move to the next clearing and search again**." | |
| else: | |
| 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's job: interpret clues, warn you away from danger, make discoveries feel alive." | |
| 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, | |
| )) | |
| 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" | |
| 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 | |
| poison_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}", | |
| poison_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.'}", | |
| )) | |
| def _mushroom_value_label(rarity): | |
| if rarity == "Legendary": return "100 spores" | |
| if rarity == "Rare": return "35 spores + special discovery" | |
| return "10 spores" | |
| def _poison_risk_label(name): | |
| return "⚠️ POISONOUS — Game Over if picked!" if name in POISONOUS_MUSHROOM_NAMES else "No obvious poison" | |
| 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: | |
| rows.append( | |
| f"* 🍄 **{entry['name']}** — {entry['rarity']} — {entry['habitat']} " | |
| f"— clue: {entry.get('clue','not studied yet')}" | |
| ) | |
| return "\n".join(rows) | |
| def _safe_collection(collection): | |
| return list(collection or []) | |
| 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.** Myco thinks the Elder is watching kindly.") | |
| 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 game_intro_markdown() -> str: | |
| return """ | |
| <div style="text-align:center;margin-bottom:.75rem;"> | |
| <h3>🌲 The Deep Forest</h3> | |
| <p style="color:#666;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. | |
| </p> | |
| </div> | |
| """ | |