Spaces:
Sleeping
Sleeping
Deploy Myco from CI
Browse files- game/engine.py +51 -8
- game/progression.py +29 -0
- tests/test_myco_core.py +11 -2
- ui/gradio_app.py +10 -6
- ui/renderers.py +53 -25
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
"
|
|
|
|
| 60 |
)
|
| 61 |
answer = _hf_companion_answer(prompt, current)
|
| 62 |
if answer:
|
| 63 |
return answer
|
| 64 |
|
| 65 |
return (
|
| 66 |
-
f"We found a {mushroom.name}!
|
| 67 |
-
f"
|
|
|
|
| 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.
|
| 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']}**.
|
|
|
|
| 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
|
| 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:**
|
| 39 |
-
and
|
| 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**
|
| 49 |
-
5. Check **📖 MycoDex** and **🗺️ World Map** to track
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 44 |
<div class="hook-steps">
|
| 45 |
-
<span>1 ·
|
| 46 |
-
<span>2 ·
|
| 47 |
-
<span>3 · Study
|
| 48 |
</div>
|
| 49 |
-
<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 = "
|
| 141 |
-
discovery = "No active discovery yet."
|
| 142 |
-
ai_job = "Myco's job:
|
|
|
|
| 143 |
else:
|
| 144 |
name = current.get("name", "Mystery mushroom")
|
| 145 |
studied = current.get("studied") == "Yes"
|
| 146 |
-
next_step = "Collect it
|
| 147 |
objective = f"Current objective: **{next_step}**."
|
| 148 |
discovery = f"Active discovery: **{name}** · Rarity: **{current.get('rarity', 'Unknown')}**"
|
| 149 |
-
|
|
|
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 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(
|
| 220 |
f' <div class="map-grid">{"".join(cards)}</div>',
|
| 221 |
'</div>',
|
| 222 |
)
|
| 223 |
)
|
| 224 |
|
| 225 |
|
| 226 |
-
def
|
| 227 |
-
"""Render a compact
|
| 228 |
state = "earned" if unlocked else "pending"
|
| 229 |
-
icon = "
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
else:
|
| 270 |
-
rows.append(
|
| 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}"
|