Spaces:
Sleeping
Sleeping
Deploy Myco from CI
Browse files- game/engine.py +102 -116
game/engine.py
CHANGED
|
@@ -17,45 +17,56 @@ except Exception:
|
|
| 17 |
try:
|
| 18 |
import spaces
|
| 19 |
except Exception:
|
| 20 |
-
spaces
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
from transformers import pipeline
|
| 23 |
|
| 24 |
from game.catalog import load_mushrooms
|
| 25 |
from game.state import collection_contains, mushroom_from_state, welcome_history
|
| 26 |
|
| 27 |
-
#
|
|
|
|
|
|
|
| 28 |
_pipeline = None
|
| 29 |
_lock = threading.Lock()
|
| 30 |
DEFAULT_MODEL_ID = "google/gemma-3-4b-it"
|
| 31 |
|
| 32 |
-
# Minimal JSON prompt suffix to instruct the model to return strict JSON
|
| 33 |
JSON_PROMPT_SUFFIX = (
|
| 34 |
"\n\nIMPORTANT: Return only a single valid JSON object (no surrounding text). "
|
| 35 |
"The JSON must be parseable by json.loads(). Example: "
|
| 36 |
'{"action":"pick","target":"Ruby Knuckle","thought":"It seems safe."}'
|
| 37 |
)
|
| 38 |
|
| 39 |
-
RARITY_WEIGHTS
|
| 40 |
-
RARITY_SCORE
|
| 41 |
-
PLAYER_HEALTH
|
| 42 |
-
POISON_PENALTY
|
| 43 |
-
POISONOUS
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
FOREST_EVENTS = [
|
| 50 |
-
{"title": "A Quiet Clearing",
|
| 51 |
{"title": "Wind Between Trees", "emoji": "🕯️", "mood": "afraid"},
|
| 52 |
]
|
| 53 |
|
| 54 |
MYSTERY_CHAPTERS = [
|
| 55 |
-
{"threshold": 0, "title": "The Wrong Memory",
|
| 56 |
-
{"threshold": 1, "title": "The Traveler's Song","clue": "A stranger's lullaby appears in the MycoDex margin."},
|
| 57 |
{"threshold": 2, "title": "The Door Under Roots","clue": "Rare spores point to a door nobody built."},
|
| 58 |
-
{"threshold": 3, "title": "The MycoDex Seed",
|
| 59 |
{"threshold": 5, "title": "The Impossible Bloom","clue": "The Impossible Mushroom was never outside the book."},
|
| 60 |
]
|
| 61 |
|
|
@@ -67,10 +78,8 @@ RARITY_CLUES = {
|
|
| 67 |
|
| 68 |
|
| 69 |
# ---------------------------------------------------------------------------
|
| 70 |
-
# Pipeline
|
| 71 |
# ---------------------------------------------------------------------------
|
| 72 |
-
# engine.py — replace _get_pipeline and _run_pipeline
|
| 73 |
-
|
| 74 |
def _get_pipeline():
|
| 75 |
global _pipeline
|
| 76 |
if _pipeline is not None:
|
|
@@ -81,19 +90,19 @@ def _get_pipeline():
|
|
| 81 |
return _pipeline
|
| 82 |
|
| 83 |
model_id = os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 84 |
-
token
|
| 85 |
|
| 86 |
try:
|
| 87 |
print(f"\n[Myco] Loading {model_id}...")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
_pipeline = pipeline(
|
| 89 |
task="text-generation",
|
| 90 |
model=model_id,
|
| 91 |
token=token,
|
| 92 |
torch_dtype=torch.bfloat16 if (torch and hasattr(torch, "bfloat16")) else None,
|
| 93 |
-
# Do NOT pass device= or device_map= here.
|
| 94 |
-
# ZeroGPU requires the model to sit on CPU at load time.
|
| 95 |
-
# The @spaces.GPU decorator moves tensors to the active GPU
|
| 96 |
-
# for the duration of that function call only.
|
| 97 |
clean_up_tokenization_spaces=False,
|
| 98 |
)
|
| 99 |
print(f"[Myco] Loaded: {model_id}")
|
|
@@ -103,10 +112,13 @@ def _get_pipeline():
|
|
| 103 |
return None
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
@spaces.GPU(duration=120)
|
| 107 |
def _run_pipeline(pipe, messages):
|
| 108 |
-
#
|
| 109 |
-
# Move the model onto it now — this is the correct place to do it.
|
| 110 |
if torch and torch.cuda.is_available():
|
| 111 |
pipe.model = pipe.model.to("cuda")
|
| 112 |
|
|
@@ -117,25 +129,18 @@ def _run_pipeline(pipe, messages):
|
|
| 117 |
temperature=0.0,
|
| 118 |
return_full_text=False,
|
| 119 |
)
|
| 120 |
-
|
|
|
|
| 121 |
# ---------------------------------------------------------------------------
|
| 122 |
# Utility: extract JSON or return text
|
| 123 |
# ---------------------------------------------------------------------------
|
| 124 |
def _extract_json_or_text(generated_text: str) -> str | None:
|
| 125 |
-
"""
|
| 126 |
-
Try to extract the last JSON object or array from the generated text.
|
| 127 |
-
If found and valid JSON, return the compact JSON string.
|
| 128 |
-
Otherwise return the trimmed raw text (or None if empty).
|
| 129 |
-
"""
|
| 130 |
if not generated_text:
|
| 131 |
return None
|
| 132 |
text = str(generated_text).strip()
|
| 133 |
|
| 134 |
-
# Non-greedy search for {...} or [...] blocks (safe, non-recursive)
|
| 135 |
simple_matches = re.findall(r"\{.*?\}|\[.*?\]", text, flags=re.DOTALL)
|
| 136 |
-
|
| 137 |
if simple_matches:
|
| 138 |
-
# Try the last match first (most likely the final JSON payload)
|
| 139 |
for candidate in reversed(simple_matches):
|
| 140 |
try:
|
| 141 |
parsed = json.loads(candidate)
|
|
@@ -143,19 +148,18 @@ def _extract_json_or_text(generated_text: str) -> str | None:
|
|
| 143 |
except Exception:
|
| 144 |
continue
|
| 145 |
|
| 146 |
-
# If no JSON found, return the trimmed text
|
| 147 |
return text or None
|
| 148 |
|
| 149 |
|
| 150 |
# ---------------------------------------------------------------------------
|
| 151 |
-
# Status
|
| 152 |
# ---------------------------------------------------------------------------
|
| 153 |
def companion_model_id():
|
| 154 |
return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 155 |
|
| 156 |
|
| 157 |
def companion_status():
|
| 158 |
-
pipe
|
| 159 |
model = companion_model_id()
|
| 160 |
if pipe:
|
| 161 |
return f"🧠 Myco AI active ({model})"
|
|
@@ -167,11 +171,9 @@ def hf_companion_status():
|
|
| 167 |
|
| 168 |
|
| 169 |
# ---------------------------------------------------------------------------
|
| 170 |
-
# LLM call —
|
| 171 |
# ---------------------------------------------------------------------------
|
| 172 |
-
|
| 173 |
def _llm(prompt: str, context: dict | None = None) -> str | None:
|
| 174 |
-
"""Call Gemma with a game-event prompt. Returns text or None."""
|
| 175 |
pipe = _get_pipeline()
|
| 176 |
if not pipe:
|
| 177 |
return None
|
|
@@ -179,7 +181,7 @@ def _llm(prompt: str, context: dict | None = None) -> str | None:
|
|
| 179 |
ctx = context or {}
|
| 180 |
mushroom_line = ""
|
| 181 |
if ctx.get("name"):
|
| 182 |
-
poison_flag
|
| 183 |
mushroom_line = (
|
| 184 |
f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')} rarity){poison_flag}. "
|
| 185 |
f"Habitat: {ctx.get('habitat','?')}. Lore: {ctx.get('lore','?')}. "
|
|
@@ -193,12 +195,9 @@ def _llm(prompt: str, context: dict | None = None) -> str | None:
|
|
| 193 |
|
| 194 |
system = f"{SYSTEM_PROMPT}\n\n{mushroom_line}\n{collection_line}\n{mystery_line}\n{score_line}"
|
| 195 |
|
| 196 |
-
# Append JSON prompt suffix to encourage strict JSON output when appropriate
|
| 197 |
-
user_content = prompt + JSON_PROMPT_SUFFIX
|
| 198 |
-
|
| 199 |
messages = [
|
| 200 |
-
{"role": "system",
|
| 201 |
-
{"role": "user",
|
| 202 |
]
|
| 203 |
|
| 204 |
try:
|
|
@@ -206,44 +205,42 @@ def _llm(prompt: str, context: dict | None = None) -> str | None:
|
|
| 206 |
print("========== MYCO OUTPUT ==========")
|
| 207 |
print(outputs)
|
| 208 |
print("=================================")
|
| 209 |
-
|
| 210 |
if isinstance(outputs, list) and outputs:
|
| 211 |
-
first
|
| 212 |
generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
|
| 213 |
else:
|
| 214 |
generated = str(outputs)
|
| 215 |
|
| 216 |
-
# If the pipeline returned a list-like message structure, handle it
|
| 217 |
if isinstance(generated, list):
|
| 218 |
last = generated[-1]
|
| 219 |
text = last.get("content") if isinstance(last, dict) else str(last)
|
| 220 |
else:
|
| 221 |
text = str(generated)
|
| 222 |
|
| 223 |
-
|
| 224 |
-
if extracted is None:
|
| 225 |
-
return None
|
| 226 |
-
return extracted
|
| 227 |
except Exception as exc:
|
| 228 |
print(f"[Myco] Inference error: {exc}")
|
| 229 |
traceback.print_exc()
|
| 230 |
return None
|
| 231 |
-
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
def _llm_with_history(history: list, user_message: str, context: dict) -> str | None:
|
| 234 |
-
"""Call Gemma with full conversation history for the chat interface."""
|
| 235 |
pipe = _get_pipeline()
|
| 236 |
if not pipe:
|
| 237 |
return None
|
| 238 |
|
| 239 |
-
ctx
|
| 240 |
mushroom_line = ""
|
| 241 |
if ctx.get("name"):
|
| 242 |
-
poison_flag
|
| 243 |
mushroom_line = (
|
| 244 |
f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')}){poison_flag}. "
|
| 245 |
f"Lore: {ctx.get('lore','?')}. "
|
| 246 |
-
f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}.
|
| 247 |
)
|
| 248 |
|
| 249 |
system = (
|
|
@@ -255,15 +252,11 @@ def _llm_with_history(history: list, user_message: str, context: dict) -> str |
|
|
| 255 |
)
|
| 256 |
|
| 257 |
messages = [{"role": "system", "content": system}]
|
| 258 |
-
|
| 259 |
-
# Add conversation history (last 6 turns)
|
| 260 |
for entry in history[-6:]:
|
| 261 |
role = entry.get("role", "assistant")
|
| 262 |
content = entry.get("content", "")
|
| 263 |
if isinstance(content, str) and content.strip():
|
| 264 |
messages.append({"role": role, "content": content})
|
| 265 |
-
|
| 266 |
-
# Append JSON prompt suffix to the user message
|
| 267 |
messages.append({"role": "user", "content": user_message + JSON_PROMPT_SUFFIX})
|
| 268 |
|
| 269 |
try:
|
|
@@ -271,8 +264,9 @@ def _llm_with_history(history: list, user_message: str, context: dict) -> str |
|
|
| 271 |
print("========== MYCO OUTPUT ==========")
|
| 272 |
print(outputs)
|
| 273 |
print("=================================")
|
|
|
|
| 274 |
if isinstance(outputs, list) and outputs:
|
| 275 |
-
first
|
| 276 |
generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
|
| 277 |
else:
|
| 278 |
generated = str(outputs)
|
|
@@ -283,10 +277,7 @@ def _llm_with_history(history: list, user_message: str, context: dict) -> str |
|
|
| 283 |
else:
|
| 284 |
text = str(generated)
|
| 285 |
|
| 286 |
-
|
| 287 |
-
if extracted is None:
|
| 288 |
-
return None
|
| 289 |
-
return extracted
|
| 290 |
except Exception as exc:
|
| 291 |
print(f"[Myco] Inference error: {exc}")
|
| 292 |
traceback.print_exc()
|
|
@@ -296,21 +287,20 @@ def _llm_with_history(history: list, user_message: str, context: dict) -> str |
|
|
| 296 |
# ---------------------------------------------------------------------------
|
| 297 |
# Context builder
|
| 298 |
# ---------------------------------------------------------------------------
|
| 299 |
-
|
| 300 |
def _ctx(current: dict | None, collection: list) -> dict:
|
| 301 |
-
count
|
| 302 |
chapter = MYSTERY_CHAPTERS[0]
|
| 303 |
for c in MYSTERY_CHAPTERS:
|
| 304 |
if count >= c["threshold"]:
|
| 305 |
chapter = c
|
| 306 |
-
score
|
| 307 |
health = _health(current, collection)
|
| 308 |
ctx: dict = {
|
| 309 |
"collection_count": count,
|
| 310 |
-
"mystery_title":
|
| 311 |
-
"mystery_clue":
|
| 312 |
-
"score":
|
| 313 |
-
"health":
|
| 314 |
}
|
| 315 |
if current:
|
| 316 |
ctx.update({
|
|
@@ -328,7 +318,6 @@ def _ctx(current: dict | None, collection: list) -> dict:
|
|
| 328 |
# ---------------------------------------------------------------------------
|
| 329 |
# Fallbacks
|
| 330 |
# ---------------------------------------------------------------------------
|
| 331 |
-
|
| 332 |
def _fallback_discover(current: dict) -> str:
|
| 333 |
name = current.get("name", "something")
|
| 334 |
rarity = current.get("rarity", "Common")
|
|
@@ -369,10 +358,9 @@ def _fallback_chat(current: dict | None) -> str:
|
|
| 369 |
# ---------------------------------------------------------------------------
|
| 370 |
# Mushroom helpers
|
| 371 |
# ---------------------------------------------------------------------------
|
| 372 |
-
|
| 373 |
def _choose_mushroom(catalog=None):
|
| 374 |
mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
|
| 375 |
-
weights
|
| 376 |
return random.choices(mushrooms, weights=weights, k=1)[0]
|
| 377 |
|
| 378 |
|
|
@@ -423,21 +411,21 @@ def _story_event(count: int) -> dict:
|
|
| 423 |
|
| 424 |
|
| 425 |
def _build_current(mushroom, collection: list) -> dict:
|
| 426 |
-
count
|
| 427 |
current = mushroom.to_dict()
|
| 428 |
-
current["poison"]
|
| 429 |
-
current["score_total"]
|
| 430 |
-
current["health"]
|
| 431 |
-
current["score_delta"]
|
| 432 |
-
current["clue"]
|
| 433 |
if count == 0:
|
| 434 |
current["clue"] = f"First clue: {mushroom.name} marks the beginning of the Spore Door trail."
|
| 435 |
event = _story_event(count)
|
| 436 |
current.update({
|
| 437 |
-
"event_title":
|
| 438 |
-
"event_emoji":
|
| 439 |
-
"myco_mood":
|
| 440 |
-
"reward_text":
|
| 441 |
})
|
| 442 |
current.update(_mystery_state(count))
|
| 443 |
return current
|
|
@@ -456,12 +444,11 @@ def _safe_collection(c) -> list:
|
|
| 456 |
|
| 457 |
|
| 458 |
# ---------------------------------------------------------------------------
|
| 459 |
-
#
|
| 460 |
# ---------------------------------------------------------------------------
|
| 461 |
-
|
| 462 |
def discover_mushroom(collection=None, catalog=None):
|
| 463 |
"""Discover a new mushroom. LLM narrates the moment."""
|
| 464 |
-
coll
|
| 465 |
mushroom = _choose_mushroom(catalog)
|
| 466 |
current = _build_current(mushroom, coll)
|
| 467 |
ctx = _ctx(current, coll)
|
|
@@ -476,7 +463,7 @@ def discover_mushroom(collection=None, catalog=None):
|
|
| 476 |
f"Mystery chapter: {current['mystery_title']}. "
|
| 477 |
"React in character. Hint at what to do next (Study, Pick, Follow Whisper, or Collect)."
|
| 478 |
)
|
| 479 |
-
reply
|
| 480 |
history = _append(welcome_history(), "assistant", reply)
|
| 481 |
return mushroom, current, history
|
| 482 |
|
|
@@ -512,18 +499,18 @@ def collect_current(current=None, collection=None, history=None):
|
|
| 512 |
if collection_contains(coll, current["name"]):
|
| 513 |
return coll, _append(hist, "assistant", f"{current['name']} is already in the MycoDex!")
|
| 514 |
|
| 515 |
-
score_delta
|
| 516 |
-
score_total
|
| 517 |
-
collected
|
| 518 |
**current,
|
| 519 |
"score_delta": str(score_delta),
|
| 520 |
"score_total": str(score_total),
|
| 521 |
"health": str(_health(current, coll)),
|
| 522 |
"reward_text": f"+{score_delta} spores",
|
| 523 |
}
|
| 524 |
-
updated_coll
|
|
|
|
| 525 |
|
| 526 |
-
ctx = _ctx(current, coll)
|
| 527 |
prompt = (
|
| 528 |
f"The player just added {current['name']} ({current.get('rarity','Common')}) to the MycoDex! "
|
| 529 |
f"+{score_delta} spores. Total score: {score_total}. "
|
|
@@ -563,9 +550,9 @@ def pick_current(current=None, collection=None, history=None):
|
|
| 563 |
reply = _llm(prompt, ctx) or _fallback_pick(current)
|
| 564 |
return coll, game_over, _append(hist, "assistant", f"💀 {reply}")
|
| 565 |
|
| 566 |
-
score_delta
|
| 567 |
-
score_total
|
| 568 |
-
picked
|
| 569 |
**current,
|
| 570 |
"picked": "Yes",
|
| 571 |
"danger": "Safe",
|
|
@@ -600,8 +587,13 @@ def follow_whisper(current=None, collection=None, history=None):
|
|
| 600 |
ctx = _ctx(current, coll)
|
| 601 |
|
| 602 |
if _is_poisonous(current) and current.get("studied") != "Yes":
|
| 603 |
-
game_over = {
|
| 604 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
prompt = (
|
| 606 |
f"The player followed a whisper but it led to POISON from {current['name']}! Game Over! "
|
| 607 |
"React with horror and a haunting mystery revelation."
|
|
@@ -610,18 +602,14 @@ def follow_whisper(current=None, collection=None, history=None):
|
|
| 610 |
return game_over, _append(hist, "assistant", reply)
|
| 611 |
|
| 612 |
mystery = _mystery_state(len(coll) + 1)
|
| 613 |
-
prompt
|
| 614 |
f"The player followed a forest whisper near {current.get('name','a mushroom')}. "
|
| 615 |
f"Mystery chapter revealed: {mystery['mystery_title']}. Clue: {mystery['mystery_clue']}. "
|
| 616 |
-
|
| 617 |
"Make Myco gasp or tremble. Hint that the MycoDex is alive and regrowing the lost forest."
|
| 618 |
)
|
| 619 |
-
reply
|
| 620 |
-
revealed = {
|
| 621 |
-
**current,
|
| 622 |
-
**mystery,
|
| 623 |
-
"whisper_followed": "Yes",
|
| 624 |
-
}
|
| 625 |
return revealed, _append(hist, "assistant", f"🌌 {reply}")
|
| 626 |
|
| 627 |
|
|
@@ -641,15 +629,13 @@ def study_current(current=None, history=None):
|
|
| 641 |
|
| 642 |
|
| 643 |
def eat_current(current=None, collection=None, history=None):
|
| 644 |
-
"""
|
| 645 |
hist = _safe_history(history)
|
| 646 |
if current is None:
|
| 647 |
return collection, _append(hist, "assistant", "There's nothing here to eat!")
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
|
|
|
| 651 |
return collection, _append(hist, "assistant", reply)
|
| 652 |
|
| 653 |
-
# ---------------------------------------------------------------------------
|
| 654 |
-
# End of file
|
| 655 |
-
# ---------------------------------------------------------------------------
|
|
|
|
| 17 |
try:
|
| 18 |
import spaces
|
| 19 |
except Exception:
|
| 20 |
+
# Local dev fallback — make @spaces.GPU a no-op decorator
|
| 21 |
+
class _FakeSpaces:
|
| 22 |
+
@staticmethod
|
| 23 |
+
def GPU(duration=60):
|
| 24 |
+
def decorator(fn):
|
| 25 |
+
return fn
|
| 26 |
+
return decorator
|
| 27 |
+
spaces = _FakeSpaces()
|
| 28 |
|
| 29 |
from transformers import pipeline
|
| 30 |
|
| 31 |
from game.catalog import load_mushrooms
|
| 32 |
from game.state import collection_contains, mushroom_from_state, welcome_history
|
| 33 |
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# Globals
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
_pipeline = None
|
| 38 |
_lock = threading.Lock()
|
| 39 |
DEFAULT_MODEL_ID = "google/gemma-3-4b-it"
|
| 40 |
|
|
|
|
| 41 |
JSON_PROMPT_SUFFIX = (
|
| 42 |
"\n\nIMPORTANT: Return only a single valid JSON object (no surrounding text). "
|
| 43 |
"The JSON must be parseable by json.loads(). Example: "
|
| 44 |
'{"action":"pick","target":"Ruby Knuckle","thought":"It seems safe."}'
|
| 45 |
)
|
| 46 |
|
| 47 |
+
RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
|
| 48 |
+
RARITY_SCORE = {"Common": 10, "Rare": 35, "Legendary": 100}
|
| 49 |
+
PLAYER_HEALTH = 3
|
| 50 |
+
POISON_PENALTY = -25
|
| 51 |
+
POISONOUS = {"Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle"}
|
| 52 |
|
| 53 |
+
SYSTEM_PROMPT = """You are Myco, a tiny sentient mushroom companion and forest guide.
|
| 54 |
+
You are curious, warm, slightly anxious about poisonous mushrooms, and deeply connected
|
| 55 |
+
to the forest mystery. You speak in short, vivid sentences. You never break character.
|
| 56 |
+
You react emotionally to discoveries — with awe for Legendary mushrooms, caution for
|
| 57 |
+
poisonous ones, and gentle wonder for Common ones. You hint at the deeper mystery of
|
| 58 |
+
the vanished forest and the MycoDex that seems to remember things it shouldn't."""
|
| 59 |
|
| 60 |
FOREST_EVENTS = [
|
| 61 |
+
{"title": "A Quiet Clearing", "emoji": "🌿", "mood": "calm"},
|
| 62 |
{"title": "Wind Between Trees", "emoji": "🕯️", "mood": "afraid"},
|
| 63 |
]
|
| 64 |
|
| 65 |
MYSTERY_CHAPTERS = [
|
| 66 |
+
{"threshold": 0, "title": "The Wrong Memory", "clue": "Myco recognises the path before you move."},
|
| 67 |
+
{"threshold": 1, "title": "The Traveler's Song", "clue": "A stranger's lullaby appears in the MycoDex margin."},
|
| 68 |
{"threshold": 2, "title": "The Door Under Roots","clue": "Rare spores point to a door nobody built."},
|
| 69 |
+
{"threshold": 3, "title": "The MycoDex Seed", "clue": "The MycoDex grows warm like a living cap."},
|
| 70 |
{"threshold": 5, "title": "The Impossible Bloom","clue": "The Impossible Mushroom was never outside the book."},
|
| 71 |
]
|
| 72 |
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
# ---------------------------------------------------------------------------
|
| 81 |
+
# Pipeline loader
|
| 82 |
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
| 83 |
def _get_pipeline():
|
| 84 |
global _pipeline
|
| 85 |
if _pipeline is not None:
|
|
|
|
| 90 |
return _pipeline
|
| 91 |
|
| 92 |
model_id = os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 93 |
+
token = os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN")
|
| 94 |
|
| 95 |
try:
|
| 96 |
print(f"\n[Myco] Loading {model_id}...")
|
| 97 |
+
# DO NOT pass device= or device_map= here.
|
| 98 |
+
# ZeroGPU requires the model to sit on CPU at load time.
|
| 99 |
+
# The @spaces.GPU decorator allocates a real GPU only for the
|
| 100 |
+
# duration of _run_pipeline, and we move the model there explicitly.
|
| 101 |
_pipeline = pipeline(
|
| 102 |
task="text-generation",
|
| 103 |
model=model_id,
|
| 104 |
token=token,
|
| 105 |
torch_dtype=torch.bfloat16 if (torch and hasattr(torch, "bfloat16")) else None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
clean_up_tokenization_spaces=False,
|
| 107 |
)
|
| 108 |
print(f"[Myco] Loaded: {model_id}")
|
|
|
|
| 112 |
return None
|
| 113 |
|
| 114 |
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# GPU runner — ONLY this function gets the @spaces.GPU decorator.
|
| 117 |
+
# Moving the model inside here ensures weights and inputs are on the same device.
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
@spaces.GPU(duration=120)
|
| 120 |
def _run_pipeline(pipe, messages):
|
| 121 |
+
# Move model to GPU now that ZeroGPU has allocated one for this call.
|
|
|
|
| 122 |
if torch and torch.cuda.is_available():
|
| 123 |
pipe.model = pipe.model.to("cuda")
|
| 124 |
|
|
|
|
| 129 |
temperature=0.0,
|
| 130 |
return_full_text=False,
|
| 131 |
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
# ---------------------------------------------------------------------------
|
| 135 |
# Utility: extract JSON or return text
|
| 136 |
# ---------------------------------------------------------------------------
|
| 137 |
def _extract_json_or_text(generated_text: str) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if not generated_text:
|
| 139 |
return None
|
| 140 |
text = str(generated_text).strip()
|
| 141 |
|
|
|
|
| 142 |
simple_matches = re.findall(r"\{.*?\}|\[.*?\]", text, flags=re.DOTALL)
|
|
|
|
| 143 |
if simple_matches:
|
|
|
|
| 144 |
for candidate in reversed(simple_matches):
|
| 145 |
try:
|
| 146 |
parsed = json.loads(candidate)
|
|
|
|
| 148 |
except Exception:
|
| 149 |
continue
|
| 150 |
|
|
|
|
| 151 |
return text or None
|
| 152 |
|
| 153 |
|
| 154 |
# ---------------------------------------------------------------------------
|
| 155 |
+
# Status
|
| 156 |
# ---------------------------------------------------------------------------
|
| 157 |
def companion_model_id():
|
| 158 |
return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 159 |
|
| 160 |
|
| 161 |
def companion_status():
|
| 162 |
+
pipe = _get_pipeline()
|
| 163 |
model = companion_model_id()
|
| 164 |
if pipe:
|
| 165 |
return f"🧠 Myco AI active ({model})"
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
# ---------------------------------------------------------------------------
|
| 174 |
+
# LLM call — single-turn
|
| 175 |
# ---------------------------------------------------------------------------
|
|
|
|
| 176 |
def _llm(prompt: str, context: dict | None = None) -> str | None:
|
|
|
|
| 177 |
pipe = _get_pipeline()
|
| 178 |
if not pipe:
|
| 179 |
return None
|
|
|
|
| 181 |
ctx = context or {}
|
| 182 |
mushroom_line = ""
|
| 183 |
if ctx.get("name"):
|
| 184 |
+
poison_flag = " ⚠️ POISONOUS" if ctx.get("name") in POISONOUS else ""
|
| 185 |
mushroom_line = (
|
| 186 |
f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')} rarity){poison_flag}. "
|
| 187 |
f"Habitat: {ctx.get('habitat','?')}. Lore: {ctx.get('lore','?')}. "
|
|
|
|
| 195 |
|
| 196 |
system = f"{SYSTEM_PROMPT}\n\n{mushroom_line}\n{collection_line}\n{mystery_line}\n{score_line}"
|
| 197 |
|
|
|
|
|
|
|
|
|
|
| 198 |
messages = [
|
| 199 |
+
{"role": "system", "content": system},
|
| 200 |
+
{"role": "user", "content": prompt + JSON_PROMPT_SUFFIX},
|
| 201 |
]
|
| 202 |
|
| 203 |
try:
|
|
|
|
| 205 |
print("========== MYCO OUTPUT ==========")
|
| 206 |
print(outputs)
|
| 207 |
print("=================================")
|
| 208 |
+
|
| 209 |
if isinstance(outputs, list) and outputs:
|
| 210 |
+
first = outputs[0]
|
| 211 |
generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
|
| 212 |
else:
|
| 213 |
generated = str(outputs)
|
| 214 |
|
|
|
|
| 215 |
if isinstance(generated, list):
|
| 216 |
last = generated[-1]
|
| 217 |
text = last.get("content") if isinstance(last, dict) else str(last)
|
| 218 |
else:
|
| 219 |
text = str(generated)
|
| 220 |
|
| 221 |
+
return _extract_json_or_text(text)
|
|
|
|
|
|
|
|
|
|
| 222 |
except Exception as exc:
|
| 223 |
print(f"[Myco] Inference error: {exc}")
|
| 224 |
traceback.print_exc()
|
| 225 |
return None
|
|
|
|
| 226 |
|
| 227 |
+
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
# LLM call — multi-turn chat
|
| 230 |
+
# ---------------------------------------------------------------------------
|
| 231 |
def _llm_with_history(history: list, user_message: str, context: dict) -> str | None:
|
|
|
|
| 232 |
pipe = _get_pipeline()
|
| 233 |
if not pipe:
|
| 234 |
return None
|
| 235 |
|
| 236 |
+
ctx = context or {}
|
| 237 |
mushroom_line = ""
|
| 238 |
if ctx.get("name"):
|
| 239 |
+
poison_flag = " ⚠️ POISONOUS" if ctx.get("name") in POISONOUS else ""
|
| 240 |
mushroom_line = (
|
| 241 |
f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')}){poison_flag}. "
|
| 242 |
f"Lore: {ctx.get('lore','?')}. "
|
| 243 |
+
f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}."
|
| 244 |
)
|
| 245 |
|
| 246 |
system = (
|
|
|
|
| 252 |
)
|
| 253 |
|
| 254 |
messages = [{"role": "system", "content": system}]
|
|
|
|
|
|
|
| 255 |
for entry in history[-6:]:
|
| 256 |
role = entry.get("role", "assistant")
|
| 257 |
content = entry.get("content", "")
|
| 258 |
if isinstance(content, str) and content.strip():
|
| 259 |
messages.append({"role": role, "content": content})
|
|
|
|
|
|
|
| 260 |
messages.append({"role": "user", "content": user_message + JSON_PROMPT_SUFFIX})
|
| 261 |
|
| 262 |
try:
|
|
|
|
| 264 |
print("========== MYCO OUTPUT ==========")
|
| 265 |
print(outputs)
|
| 266 |
print("=================================")
|
| 267 |
+
|
| 268 |
if isinstance(outputs, list) and outputs:
|
| 269 |
+
first = outputs[0]
|
| 270 |
generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
|
| 271 |
else:
|
| 272 |
generated = str(outputs)
|
|
|
|
| 277 |
else:
|
| 278 |
text = str(generated)
|
| 279 |
|
| 280 |
+
return _extract_json_or_text(text)
|
|
|
|
|
|
|
|
|
|
| 281 |
except Exception as exc:
|
| 282 |
print(f"[Myco] Inference error: {exc}")
|
| 283 |
traceback.print_exc()
|
|
|
|
| 287 |
# ---------------------------------------------------------------------------
|
| 288 |
# Context builder
|
| 289 |
# ---------------------------------------------------------------------------
|
|
|
|
| 290 |
def _ctx(current: dict | None, collection: list) -> dict:
|
| 291 |
+
count = len(collection)
|
| 292 |
chapter = MYSTERY_CHAPTERS[0]
|
| 293 |
for c in MYSTERY_CHAPTERS:
|
| 294 |
if count >= c["threshold"]:
|
| 295 |
chapter = c
|
| 296 |
+
score = _score_collection(collection)
|
| 297 |
health = _health(current, collection)
|
| 298 |
ctx: dict = {
|
| 299 |
"collection_count": count,
|
| 300 |
+
"mystery_title": chapter["title"],
|
| 301 |
+
"mystery_clue": chapter["clue"],
|
| 302 |
+
"score": score,
|
| 303 |
+
"health": health,
|
| 304 |
}
|
| 305 |
if current:
|
| 306 |
ctx.update({
|
|
|
|
| 318 |
# ---------------------------------------------------------------------------
|
| 319 |
# Fallbacks
|
| 320 |
# ---------------------------------------------------------------------------
|
|
|
|
| 321 |
def _fallback_discover(current: dict) -> str:
|
| 322 |
name = current.get("name", "something")
|
| 323 |
rarity = current.get("rarity", "Common")
|
|
|
|
| 358 |
# ---------------------------------------------------------------------------
|
| 359 |
# Mushroom helpers
|
| 360 |
# ---------------------------------------------------------------------------
|
|
|
|
| 361 |
def _choose_mushroom(catalog=None):
|
| 362 |
mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
|
| 363 |
+
weights = [RARITY_WEIGHTS.get(m.rarity, 12) for m in mushrooms]
|
| 364 |
return random.choices(mushrooms, weights=weights, k=1)[0]
|
| 365 |
|
| 366 |
|
|
|
|
| 411 |
|
| 412 |
|
| 413 |
def _build_current(mushroom, collection: list) -> dict:
|
| 414 |
+
count = len(collection)
|
| 415 |
current = mushroom.to_dict()
|
| 416 |
+
current["poison"] = "Yes" if mushroom.name in POISONOUS else "No"
|
| 417 |
+
current["score_total"] = str(_score_collection(collection))
|
| 418 |
+
current["health"] = str(_health(current, collection))
|
| 419 |
+
current["score_delta"] = "0"
|
| 420 |
+
current["clue"] = RARITY_CLUES.get(mushroom.rarity, RARITY_CLUES["Common"])
|
| 421 |
if count == 0:
|
| 422 |
current["clue"] = f"First clue: {mushroom.name} marks the beginning of the Spore Door trail."
|
| 423 |
event = _story_event(count)
|
| 424 |
current.update({
|
| 425 |
+
"event_title": event["title"],
|
| 426 |
+
"event_emoji": event["emoji"],
|
| 427 |
+
"myco_mood": event["mood"],
|
| 428 |
+
"reward_text": "Discover, then pick or collect.",
|
| 429 |
})
|
| 430 |
current.update(_mystery_state(count))
|
| 431 |
return current
|
|
|
|
| 444 |
|
| 445 |
|
| 446 |
# ---------------------------------------------------------------------------
|
| 447 |
+
# Public game actions
|
| 448 |
# ---------------------------------------------------------------------------
|
|
|
|
| 449 |
def discover_mushroom(collection=None, catalog=None):
|
| 450 |
"""Discover a new mushroom. LLM narrates the moment."""
|
| 451 |
+
coll = _safe_collection(collection)
|
| 452 |
mushroom = _choose_mushroom(catalog)
|
| 453 |
current = _build_current(mushroom, coll)
|
| 454 |
ctx = _ctx(current, coll)
|
|
|
|
| 463 |
f"Mystery chapter: {current['mystery_title']}. "
|
| 464 |
"React in character. Hint at what to do next (Study, Pick, Follow Whisper, or Collect)."
|
| 465 |
)
|
| 466 |
+
reply = _llm(prompt, ctx) or _fallback_discover(current)
|
| 467 |
history = _append(welcome_history(), "assistant", reply)
|
| 468 |
return mushroom, current, history
|
| 469 |
|
|
|
|
| 499 |
if collection_contains(coll, current["name"]):
|
| 500 |
return coll, _append(hist, "assistant", f"{current['name']} is already in the MycoDex!")
|
| 501 |
|
| 502 |
+
score_delta = _score_value(current)
|
| 503 |
+
score_total = _score_collection(coll) + score_delta
|
| 504 |
+
collected = {
|
| 505 |
**current,
|
| 506 |
"score_delta": str(score_delta),
|
| 507 |
"score_total": str(score_total),
|
| 508 |
"health": str(_health(current, coll)),
|
| 509 |
"reward_text": f"+{score_delta} spores",
|
| 510 |
}
|
| 511 |
+
updated_coll = [*coll, collected]
|
| 512 |
+
ctx = _ctx(current, coll)
|
| 513 |
|
|
|
|
| 514 |
prompt = (
|
| 515 |
f"The player just added {current['name']} ({current.get('rarity','Common')}) to the MycoDex! "
|
| 516 |
f"+{score_delta} spores. Total score: {score_total}. "
|
|
|
|
| 550 |
reply = _llm(prompt, ctx) or _fallback_pick(current)
|
| 551 |
return coll, game_over, _append(hist, "assistant", f"💀 {reply}")
|
| 552 |
|
| 553 |
+
score_delta = _score_value(current)
|
| 554 |
+
score_total = _score_collection(coll) + score_delta
|
| 555 |
+
picked = {
|
| 556 |
**current,
|
| 557 |
"picked": "Yes",
|
| 558 |
"danger": "Safe",
|
|
|
|
| 587 |
ctx = _ctx(current, coll)
|
| 588 |
|
| 589 |
if _is_poisonous(current) and current.get("studied") != "Yes":
|
| 590 |
+
game_over = {
|
| 591 |
+
**current,
|
| 592 |
+
"danger": "Poisonous",
|
| 593 |
+
"game_over": "Yes",
|
| 594 |
+
"health": "0",
|
| 595 |
+
"score_total": str(max(0, _score_collection(coll) + POISON_PENALTY)),
|
| 596 |
+
}
|
| 597 |
prompt = (
|
| 598 |
f"The player followed a whisper but it led to POISON from {current['name']}! Game Over! "
|
| 599 |
"React with horror and a haunting mystery revelation."
|
|
|
|
| 602 |
return game_over, _append(hist, "assistant", reply)
|
| 603 |
|
| 604 |
mystery = _mystery_state(len(coll) + 1)
|
| 605 |
+
prompt = (
|
| 606 |
f"The player followed a forest whisper near {current.get('name','a mushroom')}. "
|
| 607 |
f"Mystery chapter revealed: {mystery['mystery_title']}. Clue: {mystery['mystery_clue']}. "
|
| 608 |
+
"Reveal this mystery fragment dramatically. "
|
| 609 |
"Make Myco gasp or tremble. Hint that the MycoDex is alive and regrowing the lost forest."
|
| 610 |
)
|
| 611 |
+
reply = _llm(prompt, ctx) or _fallback_whisper(current)
|
| 612 |
+
revealed = {**current, **mystery, "whisper_followed": "Yes"}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
return revealed, _append(hist, "assistant", f"🌌 {reply}")
|
| 614 |
|
| 615 |
|
|
|
|
| 629 |
|
| 630 |
|
| 631 |
def eat_current(current=None, collection=None, history=None):
|
| 632 |
+
"""Eating mushrooms raw is always blocked."""
|
| 633 |
hist = _safe_history(history)
|
| 634 |
if current is None:
|
| 635 |
return collection, _append(hist, "assistant", "There's nothing here to eat!")
|
| 636 |
+
reply = (
|
| 637 |
+
f"No no no! {current.get('name','That')} could be dangerous raw! "
|
| 638 |
+
"I'll never let you eat an unidentified mushroom. Study it first!"
|
| 639 |
+
)
|
| 640 |
return collection, _append(hist, "assistant", reply)
|
| 641 |
|
|
|
|
|
|
|
|
|