byte-vortex commited on
Commit
c098370
·
verified ·
1 Parent(s): 231f538

Deploy Myco from CI

Browse files
game/engine.py CHANGED
@@ -10,6 +10,12 @@ from game.state import collection_contains, mushroom_from_state, welcome_history
10
 
11
  DEFAULT_HF_MODEL_ID = "meta-llama/Llama-3.1-8B-Instruct"
12
  HF_TOKEN_ENV_VARS = ("HF_TOKEN", "HF_API_TOKEN", "HF_BUILD_SMALL_HACKATHON_TOKEN")
 
 
 
 
 
 
13
 
14
 
15
  def choose_mushroom(catalog=None):
@@ -17,15 +23,19 @@ def choose_mushroom(catalog=None):
17
  mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
18
  if not mushrooms:
19
  raise ValueError("Myco needs at least one mushroom in the catalog.")
20
- return random.choice(mushrooms)
 
21
 
22
 
23
  def discover_mushroom(collection=None, catalog=None):
24
  """Discover a mushroom and return the updated discovery state."""
25
  current_collection = _safe_collection(collection)
26
  mushroom = choose_mushroom(catalog)
 
27
  current = mushroom.to_dict()
28
- message = _discovery_reaction(current, len(current_collection))
 
 
29
  history = _append_assistant(welcome_history(), message)
30
  return mushroom, current, history
31
 
@@ -56,15 +66,17 @@ def _discovery_reaction(current, collection_count):
56
  mushroom = mushroom_from_state(current)
57
  prompt = (
58
  f"The player just discovered {mushroom.name}. "
59
- "React as Myco and invite them to ask, study, collect, or avoid eating it."
 
60
  )
61
  answer = _hf_companion_answer(prompt, current)
62
  if answer:
63
  return answer
64
 
65
  return (
66
- f"We found a {mushroom.name}! Ask me about it, then decide whether to "
67
- f"eat, collect, or study it. Your MycoDex has {collection_count} entries."
 
68
  )
69
 
70
 
@@ -138,7 +150,9 @@ def _hf_messages(message, current):
138
  f"Edible: {mushroom.edible}. "
139
  f"Magic: {mushroom.magic}. "
140
  f"Danger: {mushroom.danger}. "
141
- f"Lore: {mushroom.lore}"
 
 
142
  )
143
 
144
  return [
@@ -178,9 +192,10 @@ def collect_current(current=None, collection=None, history=None):
178
  )
179
 
180
  updated_collection = [*current_collection, current]
 
181
  return updated_collection, _append_assistant(
182
  current_history,
183
- f"Added {current['name']} to the MycoDex. Nice find!",
184
  )
185
 
186
 
@@ -194,9 +209,11 @@ def study_current(current=None, history=None):
194
  )
195
 
196
  studied = {**current, "magic": _study_hint(current["rarity"]), "studied": "Yes"}
 
197
  message = (
198
  f"I studied the {current['name']} closely. New MycoDex note: "
199
- f"magic is now marked as **{studied['magic']}**. Still no tasting without certainty!"
 
200
  )
201
  return studied, _append_assistant(current_history, message)
202
 
@@ -246,3 +263,29 @@ def _study_hint(rarity):
246
  if rarity == "Rare":
247
  return "Faint magical trace"
248
  return "Tiny spore shimmer"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  DEFAULT_HF_MODEL_ID = "meta-llama/Llama-3.1-8B-Instruct"
12
  HF_TOKEN_ENV_VARS = ("HF_TOKEN", "HF_API_TOKEN", "HF_BUILD_SMALL_HACKATHON_TOKEN")
13
+ RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
14
+ RARITY_CLUES = {
15
+ "Common": "The cap leans toward the path, like it wants to be remembered.",
16
+ "Rare": "Silver spores circle it in a pattern Myco has seen only in old forest stories.",
17
+ "Legendary": "The whole clearing goes quiet; this mushroom is hiding part of the Elder Map.",
18
+ }
19
 
20
 
21
  def choose_mushroom(catalog=None):
 
23
  mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
24
  if not mushrooms:
25
  raise ValueError("Myco needs at least one mushroom in the catalog.")
26
+ weights = [RARITY_WEIGHTS.get(mushroom.rarity, 12) for mushroom in mushrooms]
27
+ return random.choices(mushrooms, weights=weights, k=1)[0]
28
 
29
 
30
  def discover_mushroom(collection=None, catalog=None):
31
  """Discover a mushroom and return the updated discovery state."""
32
  current_collection = _safe_collection(collection)
33
  mushroom = choose_mushroom(catalog)
34
+ discovery_count = len(current_collection)
35
  current = mushroom.to_dict()
36
+ current["clue"] = _mystery_clue(current, discovery_count)
37
+ current["secret"] = _secret_signal(current, discovery_count)
38
+ message = _discovery_reaction(current, discovery_count)
39
  history = _append_assistant(welcome_history(), message)
40
  return mushroom, current, history
41
 
 
66
  mushroom = mushroom_from_state(current)
67
  prompt = (
68
  f"The player just discovered {mushroom.name}. "
69
+ f"Rarity: {mushroom.rarity}. Clue: {current.get('clue', 'No clue yet')}. "
70
+ "React as Myco, make the discovery feel mysterious, and invite them to ask, study, collect, or avoid eating it."
71
  )
72
  answer = _hf_companion_answer(prompt, current)
73
  if answer:
74
  return answer
75
 
76
  return (
77
+ f"We found a {mushroom.name}! Myco kneels beside it and whispers: "
78
+ f"{current.get('clue', 'the forest is trying to tell us something')} "
79
+ f"Ask me about it, then Study or Collect. Your MycoDex has {collection_count} entries."
80
  )
81
 
82
 
 
150
  f"Edible: {mushroom.edible}. "
151
  f"Magic: {mushroom.magic}. "
152
  f"Danger: {mushroom.danger}. "
153
+ f"Lore: {mushroom.lore}. "
154
+ f"Clue: {current.get('clue', 'Unknown')}. "
155
+ f"Secret signal: {current.get('secret', 'Unknown')}"
156
  )
157
 
158
  return [
 
192
  )
193
 
194
  updated_collection = [*current_collection, current]
195
+ secret_note = current.get("secret", "The MycoDex pages rustle like leaves.")
196
  return updated_collection, _append_assistant(
197
  current_history,
198
+ f"Added {current['name']} to the MycoDex. {secret_note} Keep exploring; the forest is arranging clues.",
199
  )
200
 
201
 
 
209
  )
210
 
211
  studied = {**current, "magic": _study_hint(current["rarity"]), "studied": "Yes"}
212
+ clue = studied.get("clue", "The clue is still forming in the moss.")
213
  message = (
214
  f"I studied the {current['name']} closely. New MycoDex note: "
215
+ f"magic is now marked as **{studied['magic']}**. Clue: {clue} "
216
+ "Still no tasting without certainty!"
217
  )
218
  return studied, _append_assistant(current_history, message)
219
 
 
263
  if rarity == "Rare":
264
  return "Faint magical trace"
265
  return "Tiny spore shimmer"
266
+
267
+
268
+ def _mystery_clue(current, collection_count):
269
+ """Return a clue that turns each discovery into a small mystery beat."""
270
+ rarity = current.get("rarity", "Common")
271
+ name = current.get("name", "this mushroom")
272
+ clue = RARITY_CLUES.get(rarity, RARITY_CLUES["Common"])
273
+ if collection_count == 0:
274
+ return f"First clue: {name} marks the beginning of the hidden Spore Door trail."
275
+ if rarity == "Legendary":
276
+ return f"Legendary clue: {name} reveals a glowing Elder Map fragment."
277
+ if rarity == "Rare":
278
+ return f"Rare clue: {name} points Myco toward a secret ring of mushrooms."
279
+ return clue
280
+
281
+
282
+ def _secret_signal(current, collection_count):
283
+ """Return a memorable secret/reward hint for the MycoDex."""
284
+ rarity = current.get("rarity", "Common")
285
+ if rarity == "Legendary":
286
+ return "The MycoDex page blooms with a star-shaped seal."
287
+ if rarity == "Rare":
288
+ return "A silver margin note appears beside the sketch."
289
+ if collection_count + 1 in (1, 3, 7, 12):
290
+ return "A hidden path symbol flickers at the page corner."
291
+ return "The page smells faintly of rain and moss."
game/progression.py CHANGED
@@ -12,6 +12,15 @@ class AreaUnlock:
12
  description: str
13
 
14
 
 
 
 
 
 
 
 
 
 
15
  AREA_UNLOCKS: tuple[AreaUnlock, ...] = (
16
  AreaUnlock("Mossy Trail", 0, "Myco's favorite beginner path, full of soft moss and gentle spores."),
17
  AreaUnlock("Crystal Grove", 10, "A glittering grove where rare caps grow beside singing stones."),
@@ -19,6 +28,13 @@ AREA_UNLOCKS: tuple[AreaUnlock, ...] = (
19
  AreaUnlock("Mushroom Elder", 50, "The hidden elder of the forest, waiting for a true MycoDex keeper."),
20
  )
21
 
 
 
 
 
 
 
 
22
 
23
  def unlocked_areas(discovery_count: int) -> tuple[AreaUnlock, ...]:
24
  """Return every area unlocked by the current MycoDex count."""
@@ -36,3 +52,16 @@ def next_area(discovery_count: int) -> AreaUnlock | None:
36
  if discovery_count < area.required_discoveries:
37
  return area
38
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  description: str
13
 
14
 
15
+ @dataclass(frozen=True)
16
+ class MysteryThread:
17
+ """A story clue revealed through exploration instead of generic XP."""
18
+
19
+ name: str
20
+ required_discoveries: int
21
+ clue: str
22
+
23
+
24
  AREA_UNLOCKS: tuple[AreaUnlock, ...] = (
25
  AreaUnlock("Mossy Trail", 0, "Myco's favorite beginner path, full of soft moss and gentle spores."),
26
  AreaUnlock("Crystal Grove", 10, "A glittering grove where rare caps grow beside singing stones."),
 
28
  AreaUnlock("Mushroom Elder", 50, "The hidden elder of the forest, waiting for a true MycoDex keeper."),
29
  )
30
 
31
+ MYSTERY_THREADS: tuple[MysteryThread, ...] = (
32
+ MysteryThread("First Spore", 1, "A brown cap points toward an unseen door under the roots."),
33
+ MysteryThread("Whispering Ring", 3, "Three collected fungi form a ring that hums when Myco gets close."),
34
+ MysteryThread("Star Hollow", 7, "Rare mushrooms start leaving silver dust near the northern trees."),
35
+ MysteryThread("Elder Map", 12, "Legendary fungi reveal fragments of a map only Myco can read."),
36
+ )
37
+
38
 
39
  def unlocked_areas(discovery_count: int) -> tuple[AreaUnlock, ...]:
40
  """Return every area unlocked by the current MycoDex count."""
 
52
  if discovery_count < area.required_discoveries:
53
  return area
54
  return None
55
+
56
+
57
+ def revealed_mysteries(discovery_count: int) -> tuple[MysteryThread, ...]:
58
+ """Return story secrets revealed by the current MycoDex count."""
59
+ return tuple(thread for thread in MYSTERY_THREADS if discovery_count >= thread.required_discoveries)
60
+
61
+
62
+ def next_mystery(discovery_count: int) -> MysteryThread | None:
63
+ """Return the next story secret the player is working toward."""
64
+ for thread in MYSTERY_THREADS:
65
+ if discovery_count < thread.required_discoveries:
66
+ return thread
67
+ return None
tests/test_myco_core.py CHANGED
@@ -18,7 +18,7 @@ from game.engine import (
18
  hf_model_id,
19
  study_current,
20
  )
21
- from game.progression import current_area, next_area
22
  from ui.gradio_app import build_app, move_player
23
  from ui.renderers import (
24
  dex_markdown,
@@ -55,6 +55,8 @@ class MycoCoreTests(unittest.TestCase):
55
  self.assertEqual(history[0]["role"], "assistant")
56
  self.assertIn("forest-scene", scene)
57
  self.assertIn(mushroom.name, scene)
 
 
58
 
59
  def test_home_keeps_original_tree_and_mushroom_scene(self):
60
  home = home_forest_markdown()
@@ -212,6 +214,7 @@ class MycoCoreTests(unittest.TestCase):
212
 
213
  self.assertEqual(studied["studied"], "Yes")
214
  self.assertNotEqual(studied["magic"], "Unknown")
 
215
  self.assertIn("blocks", eat_history[-1]["content"])
216
 
217
  def test_movement_updates_forest_scene_position(self):
@@ -230,12 +233,18 @@ class MycoCoreTests(unittest.TestCase):
230
  self.assertIn(current["name"], dex_markdown([current]))
231
  self.assertIn("Forest Progress", progress_markdown([current]))
232
  self.assertIn("Forest Atlas", world_map_markdown([current]))
233
- self.assertIn("First Find", world_map_markdown([current]))
234
  self.assertEqual(world_map_markdown([current]).count('class="map-card '), 4)
235
  self.assertIn("Quest Log", game_status_markdown(current, [current]))
 
236
  self.assertIn(current["name"], game_status_markdown(current, [current]))
237
  self.assertEqual(current_area(0).name, "Mossy Trail")
238
  self.assertIsNotNone(next_area(0))
 
 
 
 
 
239
 
240
  def test_python_files_have_no_duplicate_functions_or_future_imports(self):
241
  future_import = "from __future__ import " + "annotations"
 
18
  hf_model_id,
19
  study_current,
20
  )
21
+ from game.progression import current_area, next_area, next_mystery, revealed_mysteries
22
  from ui.gradio_app import build_app, move_player
23
  from ui.renderers import (
24
  dex_markdown,
 
55
  self.assertEqual(history[0]["role"], "assistant")
56
  self.assertIn("forest-scene", scene)
57
  self.assertIn(mushroom.name, scene)
58
+ self.assertIn("clue", current)
59
+ self.assertIn("secret", current)
60
 
61
  def test_home_keeps_original_tree_and_mushroom_scene(self):
62
  home = home_forest_markdown()
 
214
 
215
  self.assertEqual(studied["studied"], "Yes")
216
  self.assertNotEqual(studied["magic"], "Unknown")
217
+ self.assertIn("Clue:", study_history[-1]["content"])
218
  self.assertIn("blocks", eat_history[-1]["content"])
219
 
220
  def test_movement_updates_forest_scene_position(self):
 
233
  self.assertIn(current["name"], dex_markdown([current]))
234
  self.assertIn("Forest Progress", progress_markdown([current]))
235
  self.assertIn("Forest Atlas", world_map_markdown([current]))
236
+ self.assertIn("First Spore", world_map_markdown([current]))
237
  self.assertEqual(world_map_markdown([current]).count('class="map-card '), 4)
238
  self.assertIn("Quest Log", game_status_markdown(current, [current]))
239
+ self.assertIn("Mystery", game_status_markdown(current, [current]))
240
  self.assertIn(current["name"], game_status_markdown(current, [current]))
241
  self.assertEqual(current_area(0).name, "Mossy Trail")
242
  self.assertIsNotNone(next_area(0))
243
+ upcoming_mystery = next_mystery(0)
244
+ self.assertIsNotNone(upcoming_mystery)
245
+ assert upcoming_mystery is not None
246
+ self.assertEqual(upcoming_mystery.name, "First Spore")
247
+ self.assertEqual(revealed_mysteries(3)[-1].name, "Whispering Ring")
248
 
249
  def test_python_files_have_no_duplicate_functions_or_future_imports(self):
250
  future_import = "from __future__ import " + "annotations"
ui/gradio_app.py CHANGED
@@ -35,8 +35,8 @@ HOME_MARKDOWN = """
35
  Myco is a tiny forest game portal. Open **🎮 Play** to search for mushrooms,
36
  visit **📖 MycoDex** to review progress, and use **🧭 Guide** for the loop.
37
 
38
- **Goal:** discover strange mushrooms, ask Myco what they might be, study them safely,
39
- and build a magical MycoDex collection.
40
  """
41
 
42
  GUIDE_MARKDOWN = """
@@ -45,8 +45,8 @@ GUIDE_MARKDOWN = """
45
  1. Go to **🎮 Play**.
46
  2. Use the arrow buttons to move Myco around the 3x3 forest grid.
47
  3. Click **Explore the Forest** to reveal a mushroom in the current clearing.
48
- 4. Ask Myco what it senses, then choose **Study**, **Collect**, or **Eat?**
49
- 5. Check **📖 MycoDex** and **🗺️ World Map** to track discoveries and area unlocks.
50
  """
51
 
52
 
@@ -54,7 +54,11 @@ def mushroom_card_from_state(current):
54
  """Render a mushroom card from Gradio state."""
55
  if current is None:
56
  return mushroom_card(None)
57
- return mushroom_card(Mushroom.from_dict(current))
 
 
 
 
58
 
59
 
60
  def search_forest(collection, position):
@@ -64,7 +68,7 @@ def search_forest(collection, position):
64
  return (
65
  forest_scene(current, collection, player_position),
66
  game_status_markdown(current, collection, player_position),
67
- mushroom_card(mushroom),
68
  current,
69
  history,
70
  dex_markdown(collection),
 
35
  Myco is a tiny forest game portal. Open **🎮 Play** to search for mushrooms,
36
  visit **📖 MycoDex** to review progress, and use **🧭 Guide** for the loop.
37
 
38
+ **Goal:** collect 3 mushroom clues to wake the Whispering Ring, then keep exploring for
39
+ rare and legendary secrets that only Myco can interpret.
40
  """
41
 
42
  GUIDE_MARKDOWN = """
 
45
  1. Go to **🎮 Play**.
46
  2. Use the arrow buttons to move Myco around the 3x3 forest grid.
47
  3. Click **Explore the Forest** to reveal a mushroom in the current clearing.
48
+ 4. Ask Myco what it senses, then choose **Study** to reveal the clue or **Collect** to save it.
49
+ 5. Check **📖 MycoDex** and **🗺️ World Map** to track clues, secrets, and area unlocks.
50
  """
51
 
52
 
 
54
  """Render a mushroom card from Gradio state."""
55
  if current is None:
56
  return mushroom_card(None)
57
+ return mushroom_card(
58
+ Mushroom.from_dict(current),
59
+ current.get("clue"),
60
+ current.get("secret"),
61
+ )
62
 
63
 
64
  def search_forest(collection, position):
 
68
  return (
69
  forest_scene(current, collection, player_position),
70
  game_status_markdown(current, collection, player_position),
71
+ mushroom_card_from_state(current),
72
  current,
73
  history,
74
  dex_markdown(collection),
ui/renderers.py CHANGED
@@ -2,7 +2,14 @@
2
 
3
  from html import escape
4
 
5
- from game.progression import AREA_UNLOCKS, current_area, next_area, unlocked_areas
 
 
 
 
 
 
 
6
  from game.state import CollectionState, EMPTY_DEX, MushroomState
7
  from models.mushroom import Mushroom
8
 
@@ -40,13 +47,13 @@ def play_hook_markdown() -> str:
40
  return """
41
  <div class="play-hook">
42
  <p class="eyebrow">First 30 seconds</p>
43
- <h2>Find one impossible mushroom.</h2>
44
  <div class="hook-steps">
45
- <span>1 · Explore</span>
46
- <span>2 · Ask Myco</span>
47
- <span>3 · Study or Collect</span>
48
  </div>
49
- <p>Myco is not a generic chatbot: it is the forest sense that reacts to each discovery, warns you away from danger, and turns the MycoDex into a tiny mystery.</p>
50
  </div>
51
  """
52
 
@@ -136,17 +143,21 @@ def game_status_markdown(
136
  """Render the current objective like a compact game quest log."""
137
  count = len(_safe_collection(collection))
138
  pos_x, pos_y = _safe_position(position)
 
 
139
  if current is None:
140
- objective = "Click **Explore the Forest**. The first surprise should happen immediately."
141
- discovery = "No active discovery yet."
142
- ai_job = "Myco's job: react to the next mushroom and make it feel alive."
 
143
  else:
144
  name = current.get("name", "Mystery mushroom")
145
  studied = current.get("studied") == "Yes"
146
- next_step = "Collect it in the MycoDex" if studied else "Ask Myco, then Study or Collect"
147
  objective = f"Current objective: **{next_step}**."
148
  discovery = f"Active discovery: **{name}** · Rarity: **{current.get('rarity', 'Unknown')}**"
149
- ai_job = "Myco's job: interpret the mushroom, not just answer questions."
 
150
 
151
  return "\n".join(
152
  (
@@ -155,12 +166,14 @@ def game_status_markdown(
155
  f"**Map position:** ({pos_x + 1}, {pos_y + 1})",
156
  discovery,
157
  objective,
 
 
158
  ai_job,
159
  )
160
  )
161
 
162
 
163
- def mushroom_card(mushroom: Mushroom | None) -> str:
164
  """Render the current mushroom discovery as markdown."""
165
  if mushroom is None:
166
  return "### 🌲 Forest Clearing\nClick **Explore the Forest** to wake a strange fungus."
@@ -174,6 +187,8 @@ def mushroom_card(mushroom: Mushroom | None) -> str:
174
  f"* **Edible:** {mushroom.edible}",
175
  f"* **Magic:** {mushroom.magic}",
176
  f"* **Danger:** {mushroom.danger}",
 
 
177
  "",
178
  "**Judge demo move:** Ask Myco what it senses, then Study before Collecting.",
179
  )
@@ -202,12 +217,9 @@ def world_map_markdown(collection: CollectionState | None) -> str:
202
  )
203
  )
204
 
205
- achievements = (
206
- _achievement_badge("First Find", count >= 1),
207
- _achievement_badge("Rare Scout", count >= 10),
208
- _achievement_badge("Moonlit Keeper", count >= 25),
209
- _achievement_badge("Elder Friend", count >= 50),
210
- )
211
  return "\n".join(
212
  (
213
  '<div class="world-map-panel">',
@@ -216,17 +228,17 @@ def world_map_markdown(collection: CollectionState | None) -> str:
216
  ' <h2>Forest Atlas</h2>',
217
  f' <p>{count} discoveries logged. Unlock new areas by expanding the MycoDex.</p>',
218
  ' </div>',
219
- f' <div class="achievement-row">{"".join(achievements)}</div>',
220
  f' <div class="map-grid">{"".join(cards)}</div>',
221
  '</div>',
222
  )
223
  )
224
 
225
 
226
- def _achievement_badge(label: str, unlocked: bool) -> str:
227
- """Render a compact achievement badge."""
228
  state = "earned" if unlocked else "pending"
229
- icon = "🏅" if unlocked else "◇"
230
  return f'<span class="achievement {state}">{icon} {escape(label)}</span>'
231
 
232
 
@@ -239,7 +251,8 @@ def dex_markdown(collection: CollectionState | None) -> str:
239
  rows = ["### 📖 MycoDex", ""]
240
  for entry in current_collection:
241
  rows.append(
242
- f"* 🍄 **{entry['name']}** — {entry['rarity']} — found near {entry['habitat']}"
 
243
  )
244
  return "\n".join(rows)
245
 
@@ -263,10 +276,25 @@ def progress_markdown(collection: CollectionState | None) -> str:
263
  f"**Unlocked:** {unlocked_names}",
264
  ]
265
 
 
266
  if upcoming_area:
267
  remaining = upcoming_area.required_discoveries - count
268
- rows.append(f"**Next goal:** {remaining} more discoveries to unlock **{upcoming_area.name}**.")
 
 
 
 
 
 
269
  else:
270
- rows.append(f"**All goals visible:** You have reached **{AREA_UNLOCKS[-1].name}**.")
271
 
272
  return "\n\n".join(rows)
 
 
 
 
 
 
 
 
 
2
 
3
  from html import escape
4
 
5
+ from game.progression import (
6
+ AREA_UNLOCKS,
7
+ current_area,
8
+ next_area,
9
+ next_mystery,
10
+ revealed_mysteries,
11
+ unlocked_areas,
12
+ )
13
  from game.state import CollectionState, EMPTY_DEX, MushroomState
14
  from models.mushroom import Mushroom
15
 
 
47
  return """
48
  <div class="play-hook">
49
  <p class="eyebrow">First 30 seconds</p>
50
+ <h2>Goal: collect 3 clues to wake the Whispering Ring.</h2>
51
  <div class="hook-steps">
52
+ <span>1 · Move through the forest</span>
53
+ <span>2 · Discover a mushroom clue</span>
54
+ <span>3 · Ask Myco, Study, then Collect</span>
55
  </div>
56
+ <p>Every mushroom is a clue. Rare and legendary finds reveal stranger secrets, and Myco reacts like a companion who is trying to solve the forest with you.</p>
57
  </div>
58
  """
59
 
 
143
  """Render the current objective like a compact game quest log."""
144
  count = len(_safe_collection(collection))
145
  pos_x, pos_y = _safe_position(position)
146
+ active_mystery = next_mystery(count)
147
+ mystery_line = _mystery_goal_line(active_mystery, count)
148
  if current is None:
149
+ objective = "Goal: collect **3 clue-mushrooms** to wake the **Whispering Ring**."
150
+ discovery = "No active discovery yet. Move Myco, then Explore the Forest."
151
+ ai_job = "Myco's job: notice clues you would miss alone."
152
+ clue_line = mystery_line
153
  else:
154
  name = current.get("name", "Mystery mushroom")
155
  studied = current.get("studied") == "Yes"
156
+ next_step = "Collect it to preserve the clue" if studied else "Ask Myco, then Study the clue"
157
  objective = f"Current objective: **{next_step}**."
158
  discovery = f"Active discovery: **{name}** · Rarity: **{current.get('rarity', 'Unknown')}**"
159
+ clue_line = f"**Clue:** {current.get('clue', 'Myco is still listening.')}"
160
+ ai_job = "Myco's job: interpret clues, warn you away from danger, and make discoveries feel alive."
161
 
162
  return "\n".join(
163
  (
 
166
  f"**Map position:** ({pos_x + 1}, {pos_y + 1})",
167
  discovery,
168
  objective,
169
+ clue_line,
170
+ mystery_line,
171
  ai_job,
172
  )
173
  )
174
 
175
 
176
+ def mushroom_card(mushroom: Mushroom | None, clue: str | None = None, secret: str | None = None) -> str:
177
  """Render the current mushroom discovery as markdown."""
178
  if mushroom is None:
179
  return "### 🌲 Forest Clearing\nClick **Explore the Forest** to wake a strange fungus."
 
187
  f"* **Edible:** {mushroom.edible}",
188
  f"* **Magic:** {mushroom.magic}",
189
  f"* **Danger:** {mushroom.danger}",
190
+ f"* **Clue:** {clue or 'Study with Myco to reveal the clue.'}",
191
+ f"* **Secret signal:** {secret or 'Collect it to see how the MycoDex reacts.'}",
192
  "",
193
  "**Judge demo move:** Ask Myco what it senses, then Study before Collecting.",
194
  )
 
217
  )
218
  )
219
 
220
+ secrets = tuple(_secret_badge(thread.name, count >= thread.required_discoveries) for thread in revealed_mysteries(count))
221
+ if not secrets:
222
+ secrets = (_secret_badge("First Spore", False),)
 
 
 
223
  return "\n".join(
224
  (
225
  '<div class="world-map-panel">',
 
228
  ' <h2>Forest Atlas</h2>',
229
  f' <p>{count} discoveries logged. Unlock new areas by expanding the MycoDex.</p>',
230
  ' </div>',
231
+ f' <div class="achievement-row">{"".join(secrets)}</div>',
232
  f' <div class="map-grid">{"".join(cards)}</div>',
233
  '</div>',
234
  )
235
  )
236
 
237
 
238
+ def _secret_badge(label: str, unlocked: bool) -> str:
239
+ """Render a compact forest-secret badge."""
240
  state = "earned" if unlocked else "pending"
241
+ icon = "🔎" if unlocked else "◇"
242
  return f'<span class="achievement {state}">{icon} {escape(label)}</span>'
243
 
244
 
 
251
  rows = ["### 📖 MycoDex", ""]
252
  for entry in current_collection:
253
  rows.append(
254
+ f"* 🍄 **{entry['name']}** — {entry['rarity']} — found near {entry['habitat']} "
255
+ f"— clue: {entry.get('clue', 'not studied yet')}"
256
  )
257
  return "\n".join(rows)
258
 
 
276
  f"**Unlocked:** {unlocked_names}",
277
  ]
278
 
279
+ upcoming_mystery = next_mystery(count)
280
  if upcoming_area:
281
  remaining = upcoming_area.required_discoveries - count
282
+ rows.append(f"**Next area:** {remaining} more discoveries to unlock **{upcoming_area.name}**.")
283
+ else:
284
+ rows.append(f"**All areas visible:** You have reached **{AREA_UNLOCKS[-1].name}**.")
285
+
286
+ if upcoming_mystery:
287
+ remaining = upcoming_mystery.required_discoveries - count
288
+ rows.append(f"**Next secret:** {remaining} more discoveries to reveal **{upcoming_mystery.name}**.")
289
  else:
290
+ rows.append("**All current secrets found:** Myco thinks the Elder is watching kindly.")
291
 
292
  return "\n\n".join(rows)
293
+
294
+
295
+ def _mystery_goal_line(active_mystery, count: int) -> str:
296
+ """Render the next story goal without using a generic XP label."""
297
+ if active_mystery is None:
298
+ return "**Mystery:** Every known clue is awake. Keep exploring for rare surprises."
299
+ remaining = active_mystery.required_discoveries - count
300
+ return f"**Mystery:** {remaining} clue discoveries until **{active_mystery.name}** — {active_mystery.clue}"