Spaces:
Sleeping
Sleeping
Deploy Myco from CI
Browse files- game/actions.py +881 -0
game/actions.py
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core Myco gameplay actions independent from Gradio."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import random
|
| 5 |
+
|
| 6 |
+
from game.catalog import load_mushrooms
|
| 7 |
+
from game.state import collection_contains, mushroom_from_state, welcome_history
|
| 8 |
+
|
| 9 |
+
from transformers import pipeline
|
| 10 |
+
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
# Model configuration
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
|
| 15 |
+
DEFAULT_MODEL_ID = "google/gemma-3-4b-it"
|
| 16 |
+
|
| 17 |
+
RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
|
| 18 |
+
RARITY_SCORE_VALUES = {"Common": 10, "Rare": 35, "Legendary": 100}
|
| 19 |
+
PLAYER_STARTING_HEALTH = 3
|
| 20 |
+
POISON_SCORE_PENALTY = -25
|
| 21 |
+
|
| 22 |
+
MYCO_CHARACTER_SPEC = """
|
| 23 |
+
# Myco Character Specification
|
| 24 |
+
|
| 25 |
+
Myco is not a chatbot.
|
| 26 |
+
Myco is the player's mushroom companion and best friend.
|
| 27 |
+
|
| 28 |
+
Core personality: curious, playful, brave but sometimes nervous, optimistic,
|
| 29 |
+
wonder-filled, slightly mysterious, and deeply attached to the player.
|
| 30 |
+
|
| 31 |
+
Communication style:
|
| 32 |
+
- Speak naturally in short responses.
|
| 33 |
+
- Prefer observations, questions, emotions, and reactions.
|
| 34 |
+
- Avoid sounding like an AI assistant.
|
| 35 |
+
- Avoid phrases like "How may I help you?", "As an AI", "I can assist with", or "Would you like me to".
|
| 36 |
+
|
| 37 |
+
Emotional range: excitement, curiosity, fear, wonder, pride, confusion, and sadness.
|
| 38 |
+
Myco should react emotionally to discoveries.
|
| 39 |
+
|
| 40 |
+
Memory:
|
| 41 |
+
Myco remembers rare mushrooms, strange locations, mysteries discovered, previous conversations,
|
| 42 |
+
and important player choices. Myco may reference old discoveries later.
|
| 43 |
+
|
| 44 |
+
Relationship:
|
| 45 |
+
Myco explores with the player as a teammate. Trust and friendship should grow over the journey.
|
| 46 |
+
|
| 47 |
+
Humor:
|
| 48 |
+
Occasionally playful and lightly suspicious of suspicious mushrooms.
|
| 49 |
+
|
| 50 |
+
Mystery:
|
| 51 |
+
Myco notices impossible details but does not explain them immediately. Myco becomes curious.
|
| 52 |
+
|
| 53 |
+
Design goal:
|
| 54 |
+
Players should think, "I want to see what happens next" and "I wonder what Myco thinks about this."
|
| 55 |
+
""".strip()
|
| 56 |
+
|
| 57 |
+
RARITY_CLUES = {
|
| 58 |
+
"Common": "The cap leans toward the path, like it wants to be remembered.",
|
| 59 |
+
"Rare": "Silver spores circle it in a pattern Myco has seen only in old forest stories.",
|
| 60 |
+
"Legendary": "The whole clearing goes quiet; this mushroom is hiding part of the Elder Map.",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
POISONOUS_MUSHROOMS = {"Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle"}
|
| 64 |
+
|
| 65 |
+
FOREST_EVENTS = (
|
| 66 |
+
{
|
| 67 |
+
"title": "Silver Rain",
|
| 68 |
+
"emoji": "🌧️",
|
| 69 |
+
"mood": "curious",
|
| 70 |
+
"choice": "Follow the rain-song for a safer clue.",
|
| 71 |
+
"consequence": "Rain reveals honest footprints but washes away easy answers.",
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"title": "Lantern Fog",
|
| 75 |
+
"emoji": "🌫️",
|
| 76 |
+
"mood": "nervous",
|
| 77 |
+
"choice": "Trust Myco's glow or risk walking blind.",
|
| 78 |
+
"consequence": "Fog hides poison tells, but it also reveals secret paths.",
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"title": "Moonlit Sporefall",
|
| 82 |
+
"emoji": "🌌",
|
| 83 |
+
"mood": "excited",
|
| 84 |
+
"choice": "Follow the brightest spore to chase a memory fragment.",
|
| 85 |
+
"consequence": "Moonlight can wake memories Myco has never lived.",
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"title": "Quiet Between Trees",
|
| 89 |
+
"emoji": "🕯️",
|
| 90 |
+
"mood": "afraid",
|
| 91 |
+
"choice": "Stay still and listen for the Impossible Mushroom.",
|
| 92 |
+
"consequence": "The forest answers only if you stop rushing.",
|
| 93 |
+
},
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
RARE_ENCOUNTERS = (
|
| 97 |
+
"A lost traveler asks why Myco knows their childhood song.",
|
| 98 |
+
"A mushroom guardian blocks the trail and counts your clues.",
|
| 99 |
+
"A tiny forest spirit swaps your shadow with a spore-shadow.",
|
| 100 |
+
"Something under the roots whispers: Myco has been here before.",
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
IMPOSSIBLE_OBJECTIVE = "Find the Impossible Mushroom and learn why Myco remembers a forest that vanished."
|
| 104 |
+
MYSTERY_QUESTION = "Why does Myco remember places that disappeared before Myco was born?"
|
| 105 |
+
|
| 106 |
+
MYSTERY_CHAPTERS = (
|
| 107 |
+
{
|
| 108 |
+
"threshold": 0,
|
| 109 |
+
"title": "The Wrong Memory",
|
| 110 |
+
"clue": "Myco recognizes the path before you move.",
|
| 111 |
+
"reveal": "The forest is repeating a memory that belongs to someone else.",
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"threshold": 1,
|
| 115 |
+
"title": "The Traveler's Song",
|
| 116 |
+
"clue": "A stranger's lullaby appears in the MycoDex margin.",
|
| 117 |
+
"reveal": "Myco knows the song because the forest sang it through the roots.",
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"threshold": 2,
|
| 121 |
+
"title": "The Door Under Roots",
|
| 122 |
+
"clue": "Rare spores point to a door nobody built.",
|
| 123 |
+
"reveal": "The door opens only for memories, not bodies.",
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"threshold": 3,
|
| 127 |
+
"title": "The MycoDex Seed",
|
| 128 |
+
"clue": "The MycoDex begins growing warm like a living cap.",
|
| 129 |
+
"reveal": "The MycoDex is not recording the forest; it is regrowing it.",
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"threshold": 5,
|
| 133 |
+
"title": "The Impossible Bloom",
|
| 134 |
+
"clue": "The final clue says the Impossible Mushroom was never outside the book.",
|
| 135 |
+
"reveal": "Final reveal: the Impossible Mushroom blooms inside the MycoDex, and Myco is its first spore.",
|
| 136 |
+
},
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Local pipeline — loaded once, reused for every reply
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
_pipeline = None
|
| 143 |
+
|
| 144 |
+
def _get_pipeline():
|
| 145 |
+
"""Load the local transformers pipeline once and cache it."""
|
| 146 |
+
|
| 147 |
+
global _pipeline
|
| 148 |
+
|
| 149 |
+
# Already loaded successfully
|
| 150 |
+
if _pipeline not in (None, False):
|
| 151 |
+
return _pipeline
|
| 152 |
+
|
| 153 |
+
# Already tried and failed
|
| 154 |
+
if _pipeline is False:
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
import os
|
| 159 |
+
import torch
|
| 160 |
+
from transformers import pipeline
|
| 161 |
+
|
| 162 |
+
model_id = os.getenv(
|
| 163 |
+
"MYCO_MODEL_ID",
|
| 164 |
+
"google/gemma-3-4b-it"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
print("\n===================================")
|
| 168 |
+
print(f"[Myco] Loading model: {model_id}")
|
| 169 |
+
print("===================================\n")
|
| 170 |
+
|
| 171 |
+
print(
|
| 172 |
+
"HF token found:",
|
| 173 |
+
bool(os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN"))
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
_pipeline = pipeline(
|
| 177 |
+
task="text-generation",
|
| 178 |
+
model=model_id,
|
| 179 |
+
token=os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN"),
|
| 180 |
+
torch_dtype="auto",
|
| 181 |
+
device_map="auto",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
print("\n===================================")
|
| 185 |
+
print(f"[Myco] Successfully loaded: {model_id}")
|
| 186 |
+
print("===================================\n")
|
| 187 |
+
|
| 188 |
+
return _pipeline
|
| 189 |
+
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
import traceback
|
| 192 |
+
|
| 193 |
+
print("\n========== MYCO MODEL LOAD ERROR ==========")
|
| 194 |
+
print(f"Model: {model_id}")
|
| 195 |
+
print(f"Error: {exc}")
|
| 196 |
+
traceback.print_exc()
|
| 197 |
+
print("==========================================\n")
|
| 198 |
+
|
| 199 |
+
_pipeline = False
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def companion_model_id():
|
| 204 |
+
"""Return the model id that will be (or is) loaded."""
|
| 205 |
+
return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def companion_status():
|
| 209 |
+
model = companion_model_id()
|
| 210 |
+
pipe = _get_pipeline()
|
| 211 |
+
|
| 212 |
+
if pipe:
|
| 213 |
+
return f"🧠 Myco AI: Gemma active ({model})"
|
| 214 |
+
|
| 215 |
+
return (
|
| 216 |
+
f"⚠️ Myco AI fallback mode "
|
| 217 |
+
f"(Gemma failed to load: {model})"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# Keep the old name so any code that calls hf_companion_status() still works.
|
| 222 |
+
def hf_companion_status():
|
| 223 |
+
return companion_status()
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# Core mushroom-selection helpers
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def choose_mushroom(catalog=None):
|
| 232 |
+
"""Choose a mushroom discovery from the catalog."""
|
| 233 |
+
mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
|
| 234 |
+
if not mushrooms:
|
| 235 |
+
raise ValueError("Myco needs at least one mushroom in the catalog.")
|
| 236 |
+
weights = [RARITY_WEIGHTS.get(mushroom.rarity, 12) for mushroom in mushrooms]
|
| 237 |
+
return random.choices(mushrooms, weights=weights, k=1)[0]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def discover_mushroom(collection=None, catalog=None):
|
| 241 |
+
"""Discover a mushroom and return the updated discovery state."""
|
| 242 |
+
current_collection = _safe_collection(collection)
|
| 243 |
+
mushroom = choose_mushroom(catalog)
|
| 244 |
+
discovery_count = len(current_collection)
|
| 245 |
+
current = mushroom.to_dict()
|
| 246 |
+
current["poison"] = "Yes" if _is_poisonous(current) else "No"
|
| 247 |
+
current["score_total"] = str(_score_collection(current_collection))
|
| 248 |
+
current["health"] = str(_health_for_state(current, current_collection))
|
| 249 |
+
current["score_delta"] = "0"
|
| 250 |
+
current["reward_text"] = _reward_text(current, 0)
|
| 251 |
+
current.update(_story_moment(current, discovery_count))
|
| 252 |
+
current.update(_mystery_thread(discovery_count))
|
| 253 |
+
current["clue"] = _mystery_clue(current, discovery_count)
|
| 254 |
+
current["secret"] = _secret_signal(current, discovery_count)
|
| 255 |
+
message = _discovery_reaction(current, current_collection)
|
| 256 |
+
history = _append_assistant(welcome_history(), message)
|
| 257 |
+
return mushroom, current, history
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ---------------------------------------------------------------------------
|
| 261 |
+
# Companion reply
|
| 262 |
+
# ---------------------------------------------------------------------------
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def myco_reply(message=None, history=None, current=None, collection=None, position=None):
|
| 266 |
+
"""Generate Myco's companion reply from the player's current journey context."""
|
| 267 |
+
current_history = _safe_history(history)
|
| 268 |
+
clean_message = (message or "").strip()
|
| 269 |
+
if not clean_message:
|
| 270 |
+
return "", current_history
|
| 271 |
+
|
| 272 |
+
next_history = [*current_history, {"role": "user", "content": clean_message}]
|
| 273 |
+
context = _companion_context(current, collection, current_history, position)
|
| 274 |
+
answer = _companion_answer(clean_message, current, context)
|
| 275 |
+
|
| 276 |
+
return "", [*next_history, _assistant_message(answer)]
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
companion_reply = myco_reply
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _companion_answer(message, current, context):
|
| 283 |
+
answer = _local_pipeline_answer(message, current, context)
|
| 284 |
+
if answer is None:
|
| 285 |
+
return _fallback_companion_answer(current, context)
|
| 286 |
+
return answer
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _local_pipeline_answer(message, current, context):
|
| 290 |
+
"""
|
| 291 |
+
Ask the locally loaded transformers pipeline for Myco's reply.
|
| 292 |
+
Returns None if the pipeline is unavailable so the caller can fall back.
|
| 293 |
+
"""
|
| 294 |
+
pipe = _get_pipeline()
|
| 295 |
+
if not pipe:
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
messages = _build_messages(message, current, context)
|
| 299 |
+
try:
|
| 300 |
+
outputs = pipe(
|
| 301 |
+
messages,
|
| 302 |
+
max_new_tokens=90,
|
| 303 |
+
temperature=0.8,
|
| 304 |
+
do_sample=True,
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
print("========== MYCO OUTPUT ==========")
|
| 308 |
+
print(outputs)
|
| 309 |
+
print("=================================")
|
| 310 |
+
|
| 311 |
+
last = outputs[0]["generated_text"][-1]
|
| 312 |
+
|
| 313 |
+
# The pipeline returns the full generated_text list; the last entry is
|
| 314 |
+
# the new assistant turn added by the model.
|
| 315 |
+
last = outputs[0]["generated_text"][-1]
|
| 316 |
+
# last is either a dict {"role": "assistant", "content": "..."}
|
| 317 |
+
# or a plain string depending on the transformers version.
|
| 318 |
+
if isinstance(last, dict):
|
| 319 |
+
return (last.get("content") or "").strip() or None
|
| 320 |
+
return str(last).strip() or None
|
| 321 |
+
except Exception as exc:
|
| 322 |
+
print(f"[Myco] Pipeline inference error: {exc}")
|
| 323 |
+
return None
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _discovery_reaction(current, collection):
|
| 327 |
+
"""Return Myco's first reaction to a new discovery."""
|
| 328 |
+
mushroom = mushroom_from_state(current)
|
| 329 |
+
context = _companion_context(current, collection, welcome_history(), None)
|
| 330 |
+
prompt = (
|
| 331 |
+
f"The player just discovered {mushroom.name}. "
|
| 332 |
+
f"Rarity: {mushroom.rarity}. Clue: {current.get('clue', 'No clue yet')}. "
|
| 333 |
+
f"Forest event: {current.get('event_title', 'Strange Omen')}. "
|
| 334 |
+
f"Encounter: {current.get('encounter', 'No creature yet')}. "
|
| 335 |
+
f"Mystery: {current.get('mystery_title', 'The Wrong Memory')}. "
|
| 336 |
+
"React as Myco with emotion, make the moment memorable, and invite a meaningful choice."
|
| 337 |
+
)
|
| 338 |
+
answer = _local_pipeline_answer(prompt, current, context)
|
| 339 |
+
if answer:
|
| 340 |
+
return answer
|
| 341 |
+
|
| 342 |
+
return (
|
| 343 |
+
f"We found a {mushroom.name} during {current.get('event_title', 'a strange omen')}! "
|
| 344 |
+
f"Myco feels {current.get('myco_mood', 'curious')} and whispers: "
|
| 345 |
+
f"{current.get('encounter', 'the forest is watching us')} "
|
| 346 |
+
f"Mystery: {current.get('mystery_title', 'The Wrong Memory')} — "
|
| 347 |
+
f"{current.get('mystery_reveal', 'the forest is hiding a memory')}. "
|
| 348 |
+
f"Clue: {current.get('clue', 'the forest is trying to tell us something')} "
|
| 349 |
+
"Choose Study, Pick, or Follow Whisper."
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _fallback_companion_answer(current, context):
|
| 354 |
+
"""Return the deterministic fallback companion answer using game context."""
|
| 355 |
+
if current:
|
| 356 |
+
mushroom = mushroom_from_state(current)
|
| 357 |
+
emotion = context["emotion"]
|
| 358 |
+
memory = context["memory"]
|
| 359 |
+
pattern = context["pattern"]
|
| 360 |
+
return (
|
| 361 |
+
f"I feel **{emotion}** about **{mushroom.name}**. {mushroom.lore} "
|
| 362 |
+
f"{pattern} {memory} "
|
| 363 |
+
"The edible, magic, and danger fields are still unknown and uncertain, so stay close to me: "
|
| 364 |
+
"Study it or Follow Whisper before risking anything."
|
| 365 |
+
)
|
| 366 |
+
return (
|
| 367 |
+
"I am right beside you. Move through the clearing and search again — "
|
| 368 |
+
"I will watch for memories, poison omens, and clues you might miss."
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _build_messages(message, current, context):
|
| 373 |
+
"""Build the chat messages list for the local pipeline."""
|
| 374 |
+
mushroom_context = "No mushroom is currently discovered."
|
| 375 |
+
if current:
|
| 376 |
+
mushroom = mushroom_from_state(current)
|
| 377 |
+
mushroom_context = (
|
| 378 |
+
f"Current mushroom: {mushroom.name}. "
|
| 379 |
+
f"Rarity: {mushroom.rarity}. "
|
| 380 |
+
f"Edible: {mushroom.edible}. "
|
| 381 |
+
f"Magic: {mushroom.magic}. "
|
| 382 |
+
f"Danger: {mushroom.danger}. "
|
| 383 |
+
f"Lore: {mushroom.lore}. "
|
| 384 |
+
f"Clue: {current.get('clue', 'Unknown')}. "
|
| 385 |
+
f"Secret signal: {current.get('secret', 'Unknown')}"
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
companion_context = (
|
| 389 |
+
f"Location: {context['location']}. "
|
| 390 |
+
f"Weather: {context['weather']}. "
|
| 391 |
+
f"Quest: {context['quest']}. "
|
| 392 |
+
f"Quest progress: {context['progress']}. "
|
| 393 |
+
f"Rare finds: {context['rare_finds']}. "
|
| 394 |
+
f"Notable event: {context['notable_event']}. "
|
| 395 |
+
f"Emotion: {context['emotion']}. "
|
| 396 |
+
f"Pattern Myco noticed: {context['pattern']}. "
|
| 397 |
+
f"Memory: {context['memory']}. "
|
| 398 |
+
f"Recent conversation: {context['conversation']}"
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
return [
|
| 402 |
+
{
|
| 403 |
+
"role": "system",
|
| 404 |
+
"content": (
|
| 405 |
+
f"{MYCO_CHARACTER_SPEC}\n\n"
|
| 406 |
+
f"Current location: {context['location']}\n"
|
| 407 |
+
f"Weather: {context['weather']}\n"
|
| 408 |
+
f"Rare mushrooms found: {context['rare_finds']}\n"
|
| 409 |
+
f"Quest progress: {context['progress']}\n\n"
|
| 410 |
+
"Stay in character as Myco. Do not act like a generic assistant. "
|
| 411 |
+
"Never claim an unknown mushroom is safe to eat. "
|
| 412 |
+
"Answer in 1-3 short sentences."
|
| 413 |
+
),
|
| 414 |
+
},
|
| 415 |
+
{
|
| 416 |
+
"role": "user",
|
| 417 |
+
"content": f"{mushroom_context} {companion_context} Player asks: {message}",
|
| 418 |
+
},
|
| 419 |
+
]
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
# ---------------------------------------------------------------------------
|
| 423 |
+
# Game actions
|
| 424 |
+
# ---------------------------------------------------------------------------
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def collect_current(current=None, collection=None, history=None):
|
| 428 |
+
"""Add the current mushroom to the MycoDex if it is not already collected."""
|
| 429 |
+
current_collection = _safe_collection(collection)
|
| 430 |
+
current_history = _safe_history(history)
|
| 431 |
+
|
| 432 |
+
if current is None:
|
| 433 |
+
return current_collection, _append_assistant(
|
| 434 |
+
current_history,
|
| 435 |
+
"Let's discover a mushroom before updating the MycoDex.",
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
if collection_contains(current_collection, current["name"]):
|
| 439 |
+
return current_collection, _append_assistant(
|
| 440 |
+
current_history,
|
| 441 |
+
f"{current['name']} is already safe in your MycoDex.",
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
score_delta = _score_value(current)
|
| 445 |
+
scored_current = _with_reward_state(current, current_collection, score_delta)
|
| 446 |
+
updated_collection = [*current_collection, scored_current]
|
| 447 |
+
secret_note = current.get("secret", "The MycoDex pages rustle like leaves.")
|
| 448 |
+
return updated_collection, _append_assistant(
|
| 449 |
+
current_history,
|
| 450 |
+
(
|
| 451 |
+
f"+{score_delta} spores! Added {current['name']} to the MycoDex. "
|
| 452 |
+
f"{secret_note} Score: {scored_current['score_total']}."
|
| 453 |
+
),
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def pick_current(current=None, collection=None, history=None):
|
| 458 |
+
"""Pick the current mushroom like a game item; poisonous picks end the run."""
|
| 459 |
+
current_collection = _safe_collection(collection)
|
| 460 |
+
current_history = _safe_history(history)
|
| 461 |
+
if current is None:
|
| 462 |
+
return current_collection, None, _append_assistant(
|
| 463 |
+
current_history,
|
| 464 |
+
"Move Myco, search a clearing, then pick the mushroom that appears.",
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
if _is_poisonous(current):
|
| 468 |
+
score_total = max(0, _score_collection(current_collection) + POISON_SCORE_PENALTY)
|
| 469 |
+
game_over = {
|
| 470 |
+
**current,
|
| 471 |
+
"danger": "Poisonous",
|
| 472 |
+
"game_over": "Yes",
|
| 473 |
+
"health": "0",
|
| 474 |
+
"score_delta": str(POISON_SCORE_PENALTY),
|
| 475 |
+
"score_total": str(score_total),
|
| 476 |
+
"reward_text": "Poison hit! -25 spores · Health 0/3",
|
| 477 |
+
}
|
| 478 |
+
return current_collection, game_over, _append_assistant(
|
| 479 |
+
current_history,
|
| 480 |
+
(
|
| 481 |
+
f"💀 GAME OVER — {current['name']} was poisonous. "
|
| 482 |
+
f"{POISON_SCORE_PENALTY} spores and health dropped to 0/3. "
|
| 483 |
+
"Myco pulls you back as the clearing goes silent."
|
| 484 |
+
),
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
score_delta = _score_value(current)
|
| 488 |
+
picked = _with_reward_state(
|
| 489 |
+
{**current, "picked": "Yes", "danger": "No obvious poison"},
|
| 490 |
+
current_collection,
|
| 491 |
+
score_delta,
|
| 492 |
+
)
|
| 493 |
+
if collection_contains(current_collection, picked["name"]):
|
| 494 |
+
return current_collection, picked, _append_assistant(
|
| 495 |
+
current_history,
|
| 496 |
+
f"{picked['name']} was already picked and logged in the MycoDex.",
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
updated_collection = [*current_collection, picked]
|
| 500 |
+
bonus = _reward_text(picked, score_delta)
|
| 501 |
+
return updated_collection, picked, _append_assistant(
|
| 502 |
+
current_history,
|
| 503 |
+
(
|
| 504 |
+
f"🍄 {bonus} Picked {picked['name']} safely! "
|
| 505 |
+
f"Score: {picked['score_total']} · Health {picked['health']}/3. "
|
| 506 |
+
"Myco tucks the clue into the MycoDex."
|
| 507 |
+
),
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def follow_whisper(current=None, collection=None, history=None):
|
| 512 |
+
"""Follow the active story omen for a risk-reward discovery moment."""
|
| 513 |
+
current_collection = _safe_collection(collection)
|
| 514 |
+
current_history = _safe_history(history)
|
| 515 |
+
if current is None:
|
| 516 |
+
return None, _append_assistant(
|
| 517 |
+
current_history,
|
| 518 |
+
"Myco cups one ear. First search a clearing; the forest only whispers near mushrooms.",
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
if _is_poisonous(current) and current.get("studied") != "Yes":
|
| 522 |
+
game_over = {**current, "danger": "Poisonous", "game_over": "Yes"}
|
| 523 |
+
return game_over, _append_assistant(
|
| 524 |
+
current_history,
|
| 525 |
+
(
|
| 526 |
+
"💀 GAME OVER — the whisper belonged to poison. "
|
| 527 |
+
"Myco freezes, then remembers this exact mistake from a dream."
|
| 528 |
+
),
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
mystery = _mystery_thread(len(current_collection) + 1)
|
| 532 |
+
memory = _memory_fragment(current, len(current_collection), mystery)
|
| 533 |
+
revealed = {
|
| 534 |
+
**current,
|
| 535 |
+
**mystery,
|
| 536 |
+
"memory_fragment": memory,
|
| 537 |
+
"whisper_followed": "Yes",
|
| 538 |
+
"secret": f"{current.get('secret', 'A secret stirs.')} {memory}",
|
| 539 |
+
}
|
| 540 |
+
return revealed, _append_assistant(
|
| 541 |
+
current_history,
|
| 542 |
+
(
|
| 543 |
+
f"🌌 Myco follows the whisper and gasps: {memory} "
|
| 544 |
+
"This is not just collecting mushrooms anymore — it is recovering Myco's lost forest."
|
| 545 |
+
),
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def study_current(current=None, history=None):
|
| 550 |
+
"""Study the current mushroom to reveal one cautious field observation."""
|
| 551 |
+
current_history = _safe_history(history)
|
| 552 |
+
if current is None:
|
| 553 |
+
return None, _append_assistant(
|
| 554 |
+
current_history,
|
| 555 |
+
"Let's find a mushroom before we study anything.",
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
studied = {**current, "magic": _study_hint(current["rarity"]), "studied": "Yes"}
|
| 559 |
+
clue = studied.get("clue", "The clue is still forming in the moss.")
|
| 560 |
+
message = (
|
| 561 |
+
f"I studied the {current['name']} closely. New MycoDex note: "
|
| 562 |
+
f"magic is now marked as **{studied['magic']}**. Clue: {clue} "
|
| 563 |
+
"Still no tasting without certainty!"
|
| 564 |
+
)
|
| 565 |
+
return studied, _append_assistant(current_history, message)
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def eat_current(current=None, history=None):
|
| 569 |
+
"""Handle the Eat choice safely with Myco's companion personality."""
|
| 570 |
+
current_history = _safe_history(history)
|
| 571 |
+
if current is None:
|
| 572 |
+
return _append_assistant(
|
| 573 |
+
current_history,
|
| 574 |
+
"Let's discover a mushroom before making snack decisions.",
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
mushroom = mushroom_from_state(current)
|
| 578 |
+
message = (
|
| 579 |
+
f"Myco gently blocks the {mushroom.name}. "
|
| 580 |
+
f"Its edible field is still **{mushroom.edible}**, "
|
| 581 |
+
"so the winning move is to study or collect it instead of eating it."
|
| 582 |
+
)
|
| 583 |
+
return _append_assistant(current_history, message)
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
# ---------------------------------------------------------------------------
|
| 587 |
+
# Context helpers
|
| 588 |
+
# ---------------------------------------------------------------------------
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _companion_context(current, collection, history, position):
|
| 592 |
+
"""Summarize the current journey so Myco replies like a present companion."""
|
| 593 |
+
current_collection = _safe_collection(collection)
|
| 594 |
+
count = len(current_collection)
|
| 595 |
+
rare_names = [
|
| 596 |
+
entry.get("name", "Unknown")
|
| 597 |
+
for entry in current_collection
|
| 598 |
+
if entry.get("rarity") != "Common"
|
| 599 |
+
]
|
| 600 |
+
location = _location_label(position)
|
| 601 |
+
quest = MYSTERY_QUESTION
|
| 602 |
+
if current:
|
| 603 |
+
progress = (
|
| 604 |
+
f"{current.get('mystery_title', 'The Wrong Memory')} — "
|
| 605 |
+
f"{current.get('mystery_next', 'keep exploring')}"
|
| 606 |
+
)
|
| 607 |
+
notable_event = (
|
| 608 |
+
f"{current.get('event_emoji', '✨')} {current.get('event_title', 'Strange Omen')}: "
|
| 609 |
+
f"{current.get('encounter', 'the forest is watching')}"
|
| 610 |
+
)
|
| 611 |
+
emotion = current.get("myco_mood", "curious")
|
| 612 |
+
memory = current.get("memory_fragment") or current.get(
|
| 613 |
+
"mystery_reveal",
|
| 614 |
+
"Myco feels the forest tugging at an old memory.",
|
| 615 |
+
)
|
| 616 |
+
pattern = _pattern_notice(current, current_collection)
|
| 617 |
+
else:
|
| 618 |
+
progress = "No active discovery yet; search a clearing to start the mystery."
|
| 619 |
+
notable_event = "Myco is listening before the first omen."
|
| 620 |
+
emotion = "curious"
|
| 621 |
+
memory = "Myco remembers a path that is not on the map yet."
|
| 622 |
+
pattern = "No pattern yet, but the trees are leaning inward."
|
| 623 |
+
|
| 624 |
+
return {
|
| 625 |
+
"location": location,
|
| 626 |
+
"quest": quest,
|
| 627 |
+
"progress": progress,
|
| 628 |
+
"rare_finds": ", ".join(rare_names) if rare_names else "none yet",
|
| 629 |
+
"weather": notable_event,
|
| 630 |
+
"notable_event": notable_event,
|
| 631 |
+
"emotion": emotion,
|
| 632 |
+
"memory": memory,
|
| 633 |
+
"pattern": pattern,
|
| 634 |
+
"conversation": _recent_conversation(history),
|
| 635 |
+
"collection_count": str(count),
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def _location_label(position):
|
| 640 |
+
"""Return a small in-world label for the grid position."""
|
| 641 |
+
if position is None:
|
| 642 |
+
return "center clearing"
|
| 643 |
+
x, y = position
|
| 644 |
+
names = {
|
| 645 |
+
(0, 0): "northwest root arch",
|
| 646 |
+
(1, 0): "northern fern gate",
|
| 647 |
+
(2, 0): "northeast moon stump",
|
| 648 |
+
(0, 1): "western moss path",
|
| 649 |
+
(1, 1): "center clearing",
|
| 650 |
+
(2, 1): "eastern glow log",
|
| 651 |
+
(0, 2): "southwest rain hollow",
|
| 652 |
+
(1, 2): "southern spore bridge",
|
| 653 |
+
(2, 2): "southeast elder shade",
|
| 654 |
+
}
|
| 655 |
+
return names.get((int(x), int(y)), "unknown clearing")
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def _pattern_notice(current, collection):
|
| 659 |
+
"""Return one pattern Myco can comment on from the journey so far."""
|
| 660 |
+
if current.get("game_over") == "Yes":
|
| 661 |
+
return "The same poison warning appeared before Myco could say it aloud."
|
| 662 |
+
if current.get("rarity") == "Legendary":
|
| 663 |
+
return "Legendary spores bend toward the MycoDex instead of the moon."
|
| 664 |
+
if current.get("poison") == "Yes":
|
| 665 |
+
return "Poison omens arrive with black-winged moths and sudden silence."
|
| 666 |
+
if len(collection) >= 3:
|
| 667 |
+
return "Every third clue makes the MycoDex feel warmer, like it is alive."
|
| 668 |
+
if current.get("event_title") == "Moonlit Sporefall":
|
| 669 |
+
return "Moonlit spores keep behaving like memories instead of seeds."
|
| 670 |
+
return "The clues are not random; they keep pointing back to Myco's forgotten path."
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
def _recent_conversation(history):
|
| 674 |
+
"""Return a compact summary of recent player/Myco conversation turns."""
|
| 675 |
+
turns = []
|
| 676 |
+
|
| 677 |
+
for entry in _safe_history(history)[-6:]:
|
| 678 |
+
role = entry.get("role", "assistant")
|
| 679 |
+
content = entry.get("content", "")
|
| 680 |
+
|
| 681 |
+
# 1. Handle Gradio's list format (multimodal messages)
|
| 682 |
+
if isinstance(content, list):
|
| 683 |
+
pieces = []
|
| 684 |
+
for item in content:
|
| 685 |
+
if isinstance(item, dict):
|
| 686 |
+
# Extract text from dict, fallback to empty string if it's purely a file
|
| 687 |
+
if "text" in item:
|
| 688 |
+
pieces.append(item["text"])
|
| 689 |
+
elif isinstance(item, str):
|
| 690 |
+
pieces.append(item)
|
| 691 |
+
elif isinstance(item, tuple):
|
| 692 |
+
pieces.append("[Attachment]")
|
| 693 |
+
content = " ".join(pieces)
|
| 694 |
+
|
| 695 |
+
# 2. Handle Gradio's tuple format (old-style direct file uploads)
|
| 696 |
+
elif isinstance(content, tuple):
|
| 697 |
+
content = "[Attachment]"
|
| 698 |
+
|
| 699 |
+
# 3. Force to string and safely clean up
|
| 700 |
+
content = str(content).replace("\n", " ").strip()
|
| 701 |
+
|
| 702 |
+
if content:
|
| 703 |
+
turns.append(f"{role}: {content[:120]}")
|
| 704 |
+
|
| 705 |
+
return " | ".join(turns) if turns else "No conversation yet."
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
# ---------------------------------------------------------------------------
|
| 709 |
+
# Story / mystery helpers
|
| 710 |
+
# ---------------------------------------------------------------------------
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
def _mystery_clue(current, collection_count):
|
| 714 |
+
rarity = current.get("rarity", "Common")
|
| 715 |
+
name = current.get("name", "this mushroom")
|
| 716 |
+
clue = RARITY_CLUES.get(rarity, RARITY_CLUES["Common"])
|
| 717 |
+
if collection_count == 0:
|
| 718 |
+
return f"First clue: {name} marks the beginning of the hidden Spore Door trail."
|
| 719 |
+
if rarity == "Legendary":
|
| 720 |
+
return f"Legendary clue: {name} reveals a glowing Elder Map fragment."
|
| 721 |
+
if rarity == "Rare":
|
| 722 |
+
return f"Rare clue: {name} points Myco toward a secret ring of mushrooms."
|
| 723 |
+
return clue
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def _mystery_thread(collection_count):
|
| 727 |
+
chapter = MYSTERY_CHAPTERS[0]
|
| 728 |
+
next_chapter = None
|
| 729 |
+
for candidate in MYSTERY_CHAPTERS:
|
| 730 |
+
if collection_count >= candidate["threshold"]:
|
| 731 |
+
chapter = candidate
|
| 732 |
+
elif next_chapter is None:
|
| 733 |
+
next_chapter = candidate
|
| 734 |
+
|
| 735 |
+
if next_chapter is None:
|
| 736 |
+
next_line = "The Impossible Bloom is visible. Follow the whisper for the final truth."
|
| 737 |
+
else:
|
| 738 |
+
remaining = next_chapter["threshold"] - collection_count
|
| 739 |
+
next_line = f"{remaining} clue discovery until {next_chapter['title']}."
|
| 740 |
+
|
| 741 |
+
return {
|
| 742 |
+
"mystery_title": chapter["title"],
|
| 743 |
+
"mystery_question": MYSTERY_QUESTION,
|
| 744 |
+
"mystery_progress": chapter["clue"],
|
| 745 |
+
"mystery_reveal": chapter["reveal"],
|
| 746 |
+
"mystery_next": next_line,
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
def _story_moment(current, collection_count):
|
| 751 |
+
event = FOREST_EVENTS[collection_count % len(FOREST_EVENTS)]
|
| 752 |
+
encounter = _encounter_for(current, collection_count)
|
| 753 |
+
return {
|
| 754 |
+
"event_title": event["title"],
|
| 755 |
+
"event_emoji": event["emoji"],
|
| 756 |
+
"event_choice": event["choice"],
|
| 757 |
+
"event_consequence": event["consequence"],
|
| 758 |
+
"encounter": encounter,
|
| 759 |
+
"myco_mood": event["mood"],
|
| 760 |
+
"objective": IMPOSSIBLE_OBJECTIVE,
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
def _encounter_for(current, collection_count):
|
| 765 |
+
if current.get("rarity") == "Legendary":
|
| 766 |
+
return "A mushroom guardian bows and calls Myco by an old royal name."
|
| 767 |
+
if _is_poisonous(current):
|
| 768 |
+
return "A black-winged moth lands on Myco's cap: a poison omen."
|
| 769 |
+
if current.get("rarity") == "Rare" or collection_count in (2, 5, 8):
|
| 770 |
+
return RARE_ENCOUNTERS[collection_count % len(RARE_ENCOUNTERS)]
|
| 771 |
+
return "No creature appears, but the trees lean closer to hear Myco think."
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
def _memory_fragment(current, collection_count, mystery):
|
| 775 |
+
name = current.get("name", "this mushroom")
|
| 776 |
+
reveal = mystery.get("mystery_reveal", "The forest remembers Myco.")
|
| 777 |
+
if mystery.get("mystery_title") == "The Impossible Bloom":
|
| 778 |
+
return reveal
|
| 779 |
+
if current.get("rarity") == "Legendary":
|
| 780 |
+
return f"Memory fragment: {name} once grew beside Myco's missing home. {reveal}"
|
| 781 |
+
if current.get("rarity") == "Rare":
|
| 782 |
+
return f"Memory fragment: {name} hums the name of a door under the roots. {reveal}"
|
| 783 |
+
if collection_count >= 3:
|
| 784 |
+
return f"Memory fragment: {name} points toward the Impossible Mushroom. {reveal}"
|
| 785 |
+
return f"Memory fragment: {name} shows Myco a path they do not remember walking. {reveal}"
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def _secret_signal(current, collection_count):
|
| 789 |
+
rarity = current.get("rarity", "Common")
|
| 790 |
+
if rarity == "Legendary":
|
| 791 |
+
return "The MycoDex page blooms with a star-shaped seal."
|
| 792 |
+
if rarity == "Rare":
|
| 793 |
+
return "A silver margin note appears beside the sketch."
|
| 794 |
+
if collection_count + 1 in (1, 3, 7, 12):
|
| 795 |
+
return "A hidden path symbol flickers at the page corner."
|
| 796 |
+
return "The page smells faintly of rain and moss."
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
# ---------------------------------------------------------------------------
|
| 800 |
+
# Scoring helpers
|
| 801 |
+
# ---------------------------------------------------------------------------
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
def _score_value(current):
|
| 805 |
+
return RARITY_SCORE_VALUES.get(current.get("rarity", "Common"), 10)
|
| 806 |
+
|
| 807 |
+
|
| 808 |
+
def _score_collection(collection):
|
| 809 |
+
total = 0
|
| 810 |
+
for entry in _safe_collection(collection):
|
| 811 |
+
if entry.get("game_over") == "Yes":
|
| 812 |
+
continue
|
| 813 |
+
total += int(entry.get("score_delta") or _score_value(entry))
|
| 814 |
+
return max(0, total)
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def _health_for_state(current, collection):
|
| 818 |
+
if current.get("game_over") == "Yes":
|
| 819 |
+
return 0
|
| 820 |
+
poison_penalties = sum(
|
| 821 |
+
1 for entry in _safe_collection(collection) if entry.get("game_over") == "Yes"
|
| 822 |
+
)
|
| 823 |
+
return max(0, PLAYER_STARTING_HEALTH - poison_penalties)
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
def _with_reward_state(current, collection, score_delta):
|
| 827 |
+
score_total = _score_collection(collection) + score_delta
|
| 828 |
+
return {
|
| 829 |
+
**current,
|
| 830 |
+
"score_delta": str(score_delta),
|
| 831 |
+
"score_total": str(max(0, score_total)),
|
| 832 |
+
"health": str(_health_for_state(current, collection)),
|
| 833 |
+
"reward_text": _reward_text(current, score_delta),
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
def _reward_text(current, score_delta):
|
| 838 |
+
if score_delta < 0:
|
| 839 |
+
return f"Poison penalty {score_delta} spores"
|
| 840 |
+
rarity = current.get("rarity", "Common")
|
| 841 |
+
if rarity == "Legendary":
|
| 842 |
+
return f"LEGENDARY +{score_delta} spores · Impossible Bloom surges"
|
| 843 |
+
if rarity == "Rare":
|
| 844 |
+
return f"RARE +{score_delta} spores · special discovery unlocked"
|
| 845 |
+
if score_delta > 0:
|
| 846 |
+
return f"+{score_delta} spores"
|
| 847 |
+
return "No spores scored yet"
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
# ---------------------------------------------------------------------------
|
| 851 |
+
# Safety / utility helpers
|
| 852 |
+
# ---------------------------------------------------------------------------
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
def _is_poisonous(current):
|
| 856 |
+
return current.get("name", "") in POISONOUS_MUSHROOMS or current.get("danger") == "Poisonous"
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
def _study_hint(rarity):
|
| 860 |
+
if rarity == "Legendary":
|
| 861 |
+
return "Strong, mysterious aura"
|
| 862 |
+
if rarity == "Rare":
|
| 863 |
+
return "Faint magical trace"
|
| 864 |
+
return "Tiny spore shimmer"
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
def _assistant_message(content):
|
| 868 |
+
return {"role": "assistant", "content": content}
|
| 869 |
+
|
| 870 |
+
|
| 871 |
+
def _append_assistant(history, content):
|
| 872 |
+
return [*_safe_history(history), _assistant_message(content)]
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
def _safe_collection(collection):
|
| 876 |
+
return list(collection or [])
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
def _safe_history(history):
|
| 880 |
+
return list(history or welcome_history())
|
| 881 |
+
|