Spaces:
Running
Running
Deploy Myco from CI
Browse files- game/engine.py +171 -148
game/engine.py
CHANGED
|
@@ -9,119 +9,107 @@ import re
|
|
| 9 |
import json
|
| 10 |
import traceback
|
| 11 |
|
| 12 |
-
import torch
|
| 13 |
-
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 14 |
-
|
| 15 |
from game.catalog import load_mushrooms
|
| 16 |
from game.state import collection_contains, mushroom_from_state, welcome_history
|
| 17 |
|
| 18 |
-
print("[Myco] Loading model and tokenizer for google/gemma-3-1b-it on CPU...")
|
| 19 |
-
tokenizer = AutoTokenizer.from_pretrained("google/gemma-3-1b-it")
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
)
|
| 26 |
-
print("[Myco] Successfully loaded on CPU!")
|
| 27 |
|
| 28 |
DEFAULT_MODEL_ID = "google/gemma-3-1b-it"
|
| 29 |
-
# ───────────────────────────────────────────────────────────────────────────
|
| 30 |
-
# CRITICAL FIX: Global Model Loading for Hugging Face ZeroGPU
|
| 31 |
-
# ───────────────────────────────────────────────────────────────────────────
|
| 32 |
-
MODEL_ID = os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 33 |
-
HF_TOKEN = os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN")
|
| 34 |
-
|
| 35 |
-
print(f"\n[Myco] Loading model and tokenizer for {MODEL_ID} at global scope...")
|
| 36 |
-
try:
|
| 37 |
-
_tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)
|
| 38 |
-
_model = AutoModelForCausalLM.from_pretrained(
|
| 39 |
-
MODEL_ID,
|
| 40 |
-
token=HF_TOKEN,
|
| 41 |
-
torch_dtype=torch.bfloat16,
|
| 42 |
-
device_map=None, # Must be None so ZeroGPU can wrap it safely
|
| 43 |
-
trust_remote_code=True
|
| 44 |
-
)
|
| 45 |
-
print(f"[Myco] Successfully loaded: {MODEL_ID}")
|
| 46 |
-
except Exception as exc:
|
| 47 |
-
print(f"[Myco] Global load error: {exc}")
|
| 48 |
-
_model, _tokenizer = None, None
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
def _get_model_and_tokenizer():
|
| 52 |
-
|
| 53 |
-
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
else:
|
| 74 |
-
inputs = tokenizer(str(messages_or_prompt), return_tensors="pt").to("cpu") # 👈 Changed to CPU
|
| 75 |
-
input_len = inputs["input_ids"].shape[1]
|
| 76 |
-
|
| 77 |
-
with torch.no_grad():
|
| 78 |
-
outputs = model.generate(
|
| 79 |
-
**inputs,
|
| 80 |
-
max_new_tokens=256,
|
| 81 |
-
do_sample=True,
|
| 82 |
-
temperature=0.7
|
| 83 |
)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
print(f"Error during generation: {e}")
|
| 90 |
-
return f"ERROR: {str(e)}"
|
| 91 |
-
|
| 92 |
|
| 93 |
# ---------------------------------------------------------------------------
|
| 94 |
-
#
|
| 95 |
# ---------------------------------------------------------------------------
|
| 96 |
-
def
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
continue
|
| 106 |
-
|
| 107 |
-
content = entry.get("content", "")
|
| 108 |
-
if isinstance(content, str) and content.strip():
|
| 109 |
-
raw_turns.append({"role": role, "content": content})
|
| 110 |
-
|
| 111 |
-
raw_turns.append({"role": "user", "content": user_prompt})
|
| 112 |
-
|
| 113 |
-
alternated_turns = []
|
| 114 |
-
for turn in raw_turns:
|
| 115 |
-
if alternated_turns and alternated_turns[-1]["role"] == turn["role"]:
|
| 116 |
-
alternated_turns[-1]["content"] += f"\n\n{turn['content']}"
|
| 117 |
-
else:
|
| 118 |
-
alternated_turns.append(turn)
|
| 119 |
-
|
| 120 |
-
return alternated_turns
|
| 121 |
|
| 122 |
# ---------------------------------------------------------------------------
|
| 123 |
-
#
|
| 124 |
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
JSON_PROMPT_SUFFIX = (
|
| 126 |
"\n\nRespond with a single JSON object only. No prose before or after it. "
|
| 127 |
'Example: {"action":"pick","target":"Ruby Knuckle","thought":"It seems safe."}'
|
|
@@ -159,8 +147,9 @@ RARITY_CLUES = {
|
|
| 159 |
"Legendary": "The whole clearing goes quiet — this mushroom hides part of the Elder Map.",
|
| 160 |
}
|
| 161 |
|
|
|
|
| 162 |
# ---------------------------------------------------------------------------
|
| 163 |
-
# Utility
|
| 164 |
# ---------------------------------------------------------------------------
|
| 165 |
def _extract_json_or_text(generated_text: str) -> str | None:
|
| 166 |
if not generated_text:
|
|
@@ -175,11 +164,17 @@ def _extract_json_or_text(generated_text: str) -> str | None:
|
|
| 175 |
return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
|
| 176 |
except Exception:
|
| 177 |
continue
|
|
|
|
| 178 |
return text or None
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def companion_model_id():
|
| 181 |
return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 182 |
|
|
|
|
| 183 |
def companion_status():
|
| 184 |
pipe = _get_pipeline()
|
| 185 |
model = companion_model_id()
|
|
@@ -187,11 +182,13 @@ def companion_status():
|
|
| 187 |
return f"🧠 Myco AI active ({model})"
|
| 188 |
return f"⚠️ Myco AI fallback mode ({model} failed)"
|
| 189 |
|
|
|
|
| 190 |
def hf_companion_status():
|
| 191 |
return companion_status()
|
| 192 |
|
|
|
|
| 193 |
# ---------------------------------------------------------------------------
|
| 194 |
-
# LLM
|
| 195 |
# ---------------------------------------------------------------------------
|
| 196 |
def _llm(prompt: str, context: dict | None = None) -> str | None:
|
| 197 |
pipe = _get_pipeline()
|
|
@@ -214,10 +211,18 @@ def _llm(prompt: str, context: dict | None = None) -> str | None:
|
|
| 214 |
score_line = f"Player score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
|
| 215 |
|
| 216 |
system = f"{SYSTEM_PROMPT}\n\n{mushroom_line}\n{collection_line}\n{mystery_line}\n{score_line}"
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
try:
|
| 220 |
outputs = _run_pipeline(pipe, messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
if isinstance(outputs, list) and outputs:
|
| 222 |
first = outputs[0]
|
| 223 |
generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
|
|
@@ -236,6 +241,10 @@ def _llm(prompt: str, context: dict | None = None) -> str | None:
|
|
| 236 |
traceback.print_exc()
|
| 237 |
return None
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
def _llm_with_history(history: list, user_message: str, context: dict) -> str | None:
|
| 240 |
pipe = _get_pipeline()
|
| 241 |
if not pipe:
|
|
@@ -259,17 +268,32 @@ def _llm_with_history(history: list, user_message: str, context: dict) -> str |
|
|
| 259 |
f"Score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
|
| 260 |
)
|
| 261 |
|
| 262 |
-
messages =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
try:
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
except Exception as exc:
|
| 267 |
print(f"[Myco] Inference error: {exc}")
|
| 268 |
traceback.print_exc()
|
| 269 |
return None
|
| 270 |
|
|
|
|
| 271 |
# ---------------------------------------------------------------------------
|
| 272 |
-
#
|
| 273 |
# ---------------------------------------------------------------------------
|
| 274 |
def _ctx(current: dict | None, collection: list) -> dict:
|
| 275 |
count = len(collection)
|
|
@@ -298,10 +322,15 @@ def _ctx(current: dict | None, collection: list) -> dict:
|
|
| 298 |
})
|
| 299 |
return ctx
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
def _fallback_discover(current: dict) -> str:
|
| 302 |
name = current.get("name", "something")
|
| 303 |
rarity = current.get("rarity", "Common")
|
| 304 |
-
|
|
|
|
| 305 |
return f"Wait — {name}! I've seen this before... something feels very wrong. Don't touch it yet."
|
| 306 |
if rarity == "Legendary":
|
| 307 |
return f"Oh! Oh! A {name}! The whole clearing just went silent. This is from the Elder Map!"
|
|
@@ -309,36 +338,48 @@ def _fallback_discover(current: dict) -> str:
|
|
| 309 |
return f"A {name}... I can feel it humming. Something rare is here — maybe magical."
|
| 310 |
return f"A {name}! Found near {current.get('habitat','the forest')}. Let me sense it first."
|
| 311 |
|
|
|
|
| 312 |
def _fallback_pick(current: dict) -> str:
|
| 313 |
if _is_poisonous(current):
|
| 314 |
return "💀 That was poisonous! I tried to stop you... the forest goes dark."
|
| 315 |
return f"Got {current.get('name','it')}! +{RARITY_SCORE.get(current.get('rarity','Common'),10)} spores!"
|
| 316 |
|
|
|
|
| 317 |
def _fallback_study(current: dict) -> str:
|
| 318 |
return f"I studied it carefully. Magic field updated. The clue: {RARITY_CLUES.get(current.get('rarity','Common'), '')}"
|
| 319 |
|
|
|
|
| 320 |
def _fallback_collect(current: dict) -> str:
|
| 321 |
return f"Added {current.get('name','it')} to the MycoDex! The pages feel warmer."
|
| 322 |
|
|
|
|
| 323 |
def _fallback_whisper(current: dict) -> str:
|
| 324 |
return "I followed the whisper... and remembered a path I've never walked. The mystery deepens."
|
| 325 |
|
|
|
|
| 326 |
def _fallback_chat(current: dict | None) -> str:
|
| 327 |
if current:
|
| 328 |
return f"I feel something strange about {current.get('name','this')}... stay close to me."
|
| 329 |
return "The forest is full of secrets. Move to a clearing and search — I'll watch for danger."
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
def _choose_mushroom(catalog=None):
|
| 332 |
mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
|
| 333 |
weights = [RARITY_WEIGHTS.get(m.rarity, 12) for m in mushrooms]
|
| 334 |
return random.choices(mushrooms, weights=weights, k=1)[0]
|
| 335 |
|
|
|
|
| 336 |
def _is_poisonous(current: dict) -> bool:
|
| 337 |
return current.get("name", "") in POISONOUS or current.get("danger") == "Poisonous"
|
| 338 |
|
|
|
|
| 339 |
def _score_value(current: dict) -> int:
|
| 340 |
return RARITY_SCORE.get(current.get("rarity", "Common"), 10)
|
| 341 |
|
|
|
|
| 342 |
def _score_collection(collection: list) -> int:
|
| 343 |
total = 0
|
| 344 |
for e in collection:
|
|
@@ -347,12 +388,14 @@ def _score_collection(collection: list) -> int:
|
|
| 347 |
total += int(e.get("score_delta") or _score_value(e))
|
| 348 |
return max(0, total)
|
| 349 |
|
|
|
|
| 350 |
def _health(current: dict | None, collection: list) -> int:
|
| 351 |
if current and current.get("game_over") == "Yes":
|
| 352 |
return 0
|
| 353 |
deaths = sum(1 for e in collection if e.get("game_over") == "Yes")
|
| 354 |
return max(0, PLAYER_HEALTH - deaths)
|
| 355 |
|
|
|
|
| 356 |
def _mystery_state(count: int) -> dict:
|
| 357 |
chapter, next_ch = MYSTERY_CHAPTERS[0], None
|
| 358 |
for c in MYSTERY_CHAPTERS:
|
|
@@ -370,9 +413,11 @@ def _mystery_state(count: int) -> dict:
|
|
| 370 |
"mystery_next": next_line,
|
| 371 |
}
|
| 372 |
|
|
|
|
| 373 |
def _story_event(count: int) -> dict:
|
| 374 |
return FOREST_EVENTS[count % len(FOREST_EVENTS)]
|
| 375 |
|
|
|
|
| 376 |
def _build_current(mushroom, collection: list) -> dict:
|
| 377 |
count = len(collection)
|
| 378 |
current = mushroom.to_dict()
|
|
@@ -393,19 +438,24 @@ def _build_current(mushroom, collection: list) -> dict:
|
|
| 393 |
current.update(_mystery_state(count))
|
| 394 |
return current
|
| 395 |
|
|
|
|
| 396 |
def _append(history: list, role: str, content: str) -> list:
|
| 397 |
return [*history, {"role": role, "content": content}]
|
| 398 |
|
|
|
|
| 399 |
def _safe_history(h) -> list:
|
| 400 |
return list(h or welcome_history())
|
| 401 |
|
|
|
|
| 402 |
def _safe_collection(c) -> list:
|
| 403 |
return list(c or [])
|
| 404 |
|
|
|
|
| 405 |
# ---------------------------------------------------------------------------
|
| 406 |
-
# Public
|
| 407 |
# ---------------------------------------------------------------------------
|
| 408 |
def discover_mushroom(collection=None, catalog=None):
|
|
|
|
| 409 |
coll = _safe_collection(collection)
|
| 410 |
mushroom = _choose_mushroom(catalog)
|
| 411 |
current = _build_current(mushroom, coll)
|
|
@@ -425,7 +475,10 @@ def discover_mushroom(collection=None, catalog=None):
|
|
| 425 |
history = _append(welcome_history(), "assistant", reply)
|
| 426 |
return mushroom, current, history
|
| 427 |
|
|
|
|
| 428 |
def myco_reply(message=None, history=None, current=None, collection=None, position=None):
|
|
|
|
|
|
|
| 429 |
hist = _safe_history(history)
|
| 430 |
coll = _safe_collection(collection)
|
| 431 |
clean = (message or "").strip()
|
|
@@ -439,9 +492,12 @@ def myco_reply(message=None, history=None, current=None, collection=None, positi
|
|
| 439 |
|
| 440 |
return "", _append(hist, "user", clean) + [{"role": "assistant", "content": reply}]
|
| 441 |
|
|
|
|
| 442 |
companion_reply = myco_reply
|
| 443 |
|
|
|
|
| 444 |
def collect_current(current=None, collection=None, history=None):
|
|
|
|
| 445 |
coll = _safe_collection(collection)
|
| 446 |
hist = _safe_history(history)
|
| 447 |
|
|
@@ -472,7 +528,9 @@ def collect_current(current=None, collection=None, history=None):
|
|
| 472 |
reply = _llm(prompt, ctx) or _fallback_collect(current)
|
| 473 |
return updated_coll, _append(hist, "assistant", reply)
|
| 474 |
|
|
|
|
| 475 |
def pick_current(current=None, collection=None, history=None):
|
|
|
|
| 476 |
coll = _safe_collection(collection)
|
| 477 |
hist = _safe_history(history)
|
| 478 |
|
|
@@ -524,7 +582,9 @@ def pick_current(current=None, collection=None, history=None):
|
|
| 524 |
reply = _llm(prompt, ctx) or _fallback_pick(current)
|
| 525 |
return updated_coll, picked, _append(hist, "assistant", f"🍄 {reply}")
|
| 526 |
|
|
|
|
| 527 |
def follow_whisper(current=None, collection=None, history=None):
|
|
|
|
| 528 |
coll = _safe_collection(collection)
|
| 529 |
hist = _safe_history(history)
|
| 530 |
|
|
@@ -560,8 +620,9 @@ def follow_whisper(current=None, collection=None, history=None):
|
|
| 560 |
revealed = {**current, **mystery, "whisper_followed": "Yes"}
|
| 561 |
return revealed, _append(hist, "assistant", f"🌌 {reply}")
|
| 562 |
|
| 563 |
-
|
| 564 |
def study_current(current=None, history=None):
|
|
|
|
| 565 |
hist = _safe_history(history)
|
| 566 |
|
| 567 |
if current is None:
|
|
@@ -572,55 +633,17 @@ def study_current(current=None, history=None):
|
|
| 572 |
"Provide a concise field observation and one hint about its magic or danger."
|
| 573 |
)
|
| 574 |
reply = _llm(prompt, _ctx(current, [])) or _fallback_study(current)
|
| 575 |
-
|
| 576 |
-
updated_current = dict(current)
|
| 577 |
-
updated_current["studied"] = "Yes"
|
| 578 |
-
return updated_current, _append(hist, "assistant", reply)
|
| 579 |
|
| 580 |
-
# ── FIX: Correct layout signature and returns single history tracking ──
|
| 581 |
-
def eat_current(current=None, history=None):
|
| 582 |
-
hist = _safe_history(history)
|
| 583 |
|
|
|
|
|
|
|
|
|
|
| 584 |
if current is None:
|
| 585 |
-
return _append(hist, "assistant", "There's nothing here to eat!")
|
| 586 |
-
|
| 587 |
-
ctx = _ctx(current, [])
|
| 588 |
-
prompt = (
|
| 589 |
-
f"The player is attempting to directly EAT the raw mushroom: {current.get('name','unknown')}. "
|
| 590 |
-
"This is highly forbidden, unsafe, and unidentified! React as Myco with absolute dynamic panic, "
|
| 591 |
-
"gently scold them in character for trying something so dangerous, and assertively tell them "
|
| 592 |
-
"they need to Study it instead of eating it!"
|
| 593 |
-
)
|
| 594 |
-
|
| 595 |
-
fallback_reply = (
|
| 596 |
f"No no no! {current.get('name','That')} could be dangerous raw! "
|
| 597 |
"I'll never let you eat an unidentified mushroom. Study it first!"
|
| 598 |
)
|
| 599 |
-
|
| 600 |
-
reply = _llm(prompt, ctx) or fallback_reply
|
| 601 |
-
return _append(hist, "assistant", reply)
|
| 602 |
-
|
| 603 |
-
def get_myco_narrative(current=None):
|
| 604 |
-
prompt = (
|
| 605 |
-
"You are Myco, a tiny, emotional mushroom companion. "
|
| 606 |
-
"Write a whimsical, mysterious thought about the forest. "
|
| 607 |
-
"DO NOT use JSON. Do NOT use action tags. Just speak naturally."
|
| 608 |
-
)
|
| 609 |
-
|
| 610 |
-
context = {}
|
| 611 |
-
if current:
|
| 612 |
-
context = {"name": current.get("name"), "rarity": current.get("rarity")}
|
| 613 |
-
|
| 614 |
-
reply = _llm(prompt, context=context)
|
| 615 |
-
|
| 616 |
-
if reply:
|
| 617 |
-
if reply.strip().startswith("{"):
|
| 618 |
-
try:
|
| 619 |
-
data = json.loads(reply)
|
| 620 |
-
return data.get("thought", "The forest feels deep today... ✨")
|
| 621 |
-
except:
|
| 622 |
-
pass
|
| 623 |
-
return reply
|
| 624 |
-
|
| 625 |
-
return "The forest feels deep today... ✨"
|
| 626 |
|
|
|
|
| 9 |
import json
|
| 10 |
import traceback
|
| 11 |
|
|
|
|
|
|
|
|
|
|
| 12 |
from game.catalog import load_mushrooms
|
| 13 |
from game.state import collection_contains, mushroom_from_state, welcome_history
|
| 14 |
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
import os
|
| 17 |
+
import torch
|
| 18 |
+
import spaces
|
| 19 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
|
|
|
|
|
| 20 |
|
| 21 |
DEFAULT_MODEL_ID = "google/gemma-3-1b-it"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
# Global singletons
|
| 24 |
+
_model = None
|
| 25 |
+
_tokenizer = None
|
| 26 |
+
_lock = threading.Lock() # Ensure this matches your existing lock variable name
|
| 27 |
|
| 28 |
def _get_model_and_tokenizer():
|
| 29 |
+
global _model, _tokenizer
|
| 30 |
+
if _model is not None and _tokenizer is not None:
|
| 31 |
+
return _model, _tokenizer
|
| 32 |
|
| 33 |
+
with _lock:
|
| 34 |
+
if _model is not None and _tokenizer is not None:
|
| 35 |
+
return _model, _tokenizer
|
| 36 |
|
| 37 |
+
model_id = os.getenv("MYCO_MODEL_ID", "google/gemma-3-1b-it")
|
| 38 |
+
token = os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN")
|
| 39 |
|
| 40 |
+
try:
|
| 41 |
+
print(f"\n[Myco] Loading model and tokenizer for {model_id}...")
|
| 42 |
+
|
| 43 |
+
# Load tokenizer
|
| 44 |
+
_tokenizer = AutoTokenizer.from_pretrained(model_id, token=token)
|
| 45 |
+
|
| 46 |
+
# Load model strictly on CPU with bfloat16 precision
|
| 47 |
+
_model = AutoModelForCausalLM.from_pretrained(
|
| 48 |
+
model_id,
|
| 49 |
+
token=token,
|
| 50 |
+
torch_dtype=torch.bfloat16,
|
| 51 |
+
device_map=None, # CRITICAL: Must be None so ZeroGPU can hook it safely
|
| 52 |
+
trust_remote_code=True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
)
|
| 54 |
+
print(f"[Myco] Successfully loaded: {model_id}")
|
| 55 |
+
return _model, _tokenizer
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
print(f"[Myco] Load error: {exc}")
|
| 58 |
+
return None, None
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
# ---------------------------------------------------------------------------
|
| 61 |
+
# Pipeline loader
|
| 62 |
# ---------------------------------------------------------------------------
|
| 63 |
+
def _get_pipeline():
|
| 64 |
+
""" Legacy compatibility wrapper so companion_status() and other
|
| 65 |
+
startup hooks don't throw a NameError.
|
| 66 |
+
"""
|
| 67 |
+
model, tokenizer = _get_model_and_tokenizer()
|
| 68 |
+
if model is not None and tokenizer is not None:
|
| 69 |
+
# Return the model instance so 'if pipe is not None' checks pass successfully
|
| 70 |
+
return model
|
| 71 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
# ---------------------------------------------------------------------------
|
| 74 |
+
# GPU runner — ONLY this function gets the @spaces.GPU decorator.
|
| 75 |
# ---------------------------------------------------------------------------
|
| 76 |
+
@spaces.GPU
|
| 77 |
+
def _run_pipeline(pipe_ignored, messages):
|
| 78 |
+
# 1. Fetch our raw model and tokenizer singletons
|
| 79 |
+
model, tokenizer = _get_model_and_tokenizer()
|
| 80 |
+
if model is None or tokenizer is None:
|
| 81 |
+
return "The forest is silent. (Model loading failed)"
|
| 82 |
+
|
| 83 |
+
# CRITICAL: Do NOT call model.to("cuda") manually here.
|
| 84 |
+
# ZeroGPU's @spaces.GPU decorator handles the weight migration automatically.
|
| 85 |
+
|
| 86 |
+
# 2. Build the chat template structure natively
|
| 87 |
+
formatted_prompt = tokenizer.apply_chat_template(
|
| 88 |
+
messages,
|
| 89 |
+
tokenize=False,
|
| 90 |
+
add_generation_prompt=True
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# 3. Tokenize and dynamically map inputs to the exact device the model is currently using
|
| 94 |
+
inputs = tokenizer(formatted_prompt, return_tensors="pt").to(model.device)
|
| 95 |
+
|
| 96 |
+
# 4. Generate clean text without any configuration conflicts
|
| 97 |
+
with torch.no_grad():
|
| 98 |
+
outputs = model.generate(
|
| 99 |
+
**inputs,
|
| 100 |
+
max_new_tokens=256,
|
| 101 |
+
do_sample=True,
|
| 102 |
+
temperature=0.7,
|
| 103 |
+
pad_token_id=tokenizer.eos_token_id
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# 5. Extract only the newly generated text tokens
|
| 107 |
+
input_length = inputs.input_ids.shape[1]
|
| 108 |
+
generated_tokens = outputs[0][input_length:]
|
| 109 |
+
|
| 110 |
+
return tokenizer.decode(generated_tokens, skip_special_tokens=True)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
JSON_PROMPT_SUFFIX = (
|
| 114 |
"\n\nRespond with a single JSON object only. No prose before or after it. "
|
| 115 |
'Example: {"action":"pick","target":"Ruby Knuckle","thought":"It seems safe."}'
|
|
|
|
| 147 |
"Legendary": "The whole clearing goes quiet — this mushroom hides part of the Elder Map.",
|
| 148 |
}
|
| 149 |
|
| 150 |
+
|
| 151 |
# ---------------------------------------------------------------------------
|
| 152 |
+
# Utility: extract JSON or return text
|
| 153 |
# ---------------------------------------------------------------------------
|
| 154 |
def _extract_json_or_text(generated_text: str) -> str | None:
|
| 155 |
if not generated_text:
|
|
|
|
| 164 |
return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
|
| 165 |
except Exception:
|
| 166 |
continue
|
| 167 |
+
|
| 168 |
return text or None
|
| 169 |
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# Status
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
def companion_model_id():
|
| 175 |
return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 176 |
|
| 177 |
+
|
| 178 |
def companion_status():
|
| 179 |
pipe = _get_pipeline()
|
| 180 |
model = companion_model_id()
|
|
|
|
| 182 |
return f"🧠 Myco AI active ({model})"
|
| 183 |
return f"⚠️ Myco AI fallback mode ({model} failed)"
|
| 184 |
|
| 185 |
+
|
| 186 |
def hf_companion_status():
|
| 187 |
return companion_status()
|
| 188 |
|
| 189 |
+
|
| 190 |
# ---------------------------------------------------------------------------
|
| 191 |
+
# LLM call — single-turn
|
| 192 |
# ---------------------------------------------------------------------------
|
| 193 |
def _llm(prompt: str, context: dict | None = None) -> str | None:
|
| 194 |
pipe = _get_pipeline()
|
|
|
|
| 211 |
score_line = f"Player score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
|
| 212 |
|
| 213 |
system = f"{SYSTEM_PROMPT}\n\n{mushroom_line}\n{collection_line}\n{mystery_line}\n{score_line}"
|
| 214 |
+
|
| 215 |
+
messages = [
|
| 216 |
+
{"role": "system", "content": system},
|
| 217 |
+
{"role": "user", "content": prompt + JSON_PROMPT_SUFFIX},
|
| 218 |
+
]
|
| 219 |
|
| 220 |
try:
|
| 221 |
outputs = _run_pipeline(pipe, messages)
|
| 222 |
+
print("========== MYCO OUTPUT ==========")
|
| 223 |
+
print(outputs)
|
| 224 |
+
print("=================================")
|
| 225 |
+
|
| 226 |
if isinstance(outputs, list) and outputs:
|
| 227 |
first = outputs[0]
|
| 228 |
generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
|
|
|
|
| 241 |
traceback.print_exc()
|
| 242 |
return None
|
| 243 |
|
| 244 |
+
|
| 245 |
+
# ---------------------------------------------------------------------------
|
| 246 |
+
# LLM call — multi-turn chat
|
| 247 |
+
# ---------------------------------------------------------------------------
|
| 248 |
def _llm_with_history(history: list, user_message: str, context: dict) -> str | None:
|
| 249 |
pipe = _get_pipeline()
|
| 250 |
if not pipe:
|
|
|
|
| 268 |
f"Score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
|
| 269 |
)
|
| 270 |
|
| 271 |
+
messages = [{"role": "system", "content": system}]
|
| 272 |
+
for entry in history[-6:]:
|
| 273 |
+
role = entry.get("role", "assistant")
|
| 274 |
+
content = entry.get("content", "")
|
| 275 |
+
if isinstance(content, str) and content.strip():
|
| 276 |
+
messages.append({"role": role, "content": content})
|
| 277 |
+
# Chat replies: no JSON forcing — Myco speaks naturally here.
|
| 278 |
+
messages.append({"role": "user", "content": user_message})
|
| 279 |
|
| 280 |
try:
|
| 281 |
+
reply = _run_pipeline(pipe, messages)
|
| 282 |
+
|
| 283 |
+
print("========== MYCO OUTPUT ==========")
|
| 284 |
+
print(reply)
|
| 285 |
+
print("=================================")
|
| 286 |
+
|
| 287 |
+
return reply
|
| 288 |
+
|
| 289 |
except Exception as exc:
|
| 290 |
print(f"[Myco] Inference error: {exc}")
|
| 291 |
traceback.print_exc()
|
| 292 |
return None
|
| 293 |
|
| 294 |
+
|
| 295 |
# ---------------------------------------------------------------------------
|
| 296 |
+
# Context builder
|
| 297 |
# ---------------------------------------------------------------------------
|
| 298 |
def _ctx(current: dict | None, collection: list) -> dict:
|
| 299 |
count = len(collection)
|
|
|
|
| 322 |
})
|
| 323 |
return ctx
|
| 324 |
|
| 325 |
+
|
| 326 |
+
# ---------------------------------------------------------------------------
|
| 327 |
+
# Fallbacks
|
| 328 |
+
# ---------------------------------------------------------------------------
|
| 329 |
def _fallback_discover(current: dict) -> str:
|
| 330 |
name = current.get("name", "something")
|
| 331 |
rarity = current.get("rarity", "Common")
|
| 332 |
+
poison = current.get("name", "") in POISONOUS
|
| 333 |
+
if poison:
|
| 334 |
return f"Wait — {name}! I've seen this before... something feels very wrong. Don't touch it yet."
|
| 335 |
if rarity == "Legendary":
|
| 336 |
return f"Oh! Oh! A {name}! The whole clearing just went silent. This is from the Elder Map!"
|
|
|
|
| 338 |
return f"A {name}... I can feel it humming. Something rare is here — maybe magical."
|
| 339 |
return f"A {name}! Found near {current.get('habitat','the forest')}. Let me sense it first."
|
| 340 |
|
| 341 |
+
|
| 342 |
def _fallback_pick(current: dict) -> str:
|
| 343 |
if _is_poisonous(current):
|
| 344 |
return "💀 That was poisonous! I tried to stop you... the forest goes dark."
|
| 345 |
return f"Got {current.get('name','it')}! +{RARITY_SCORE.get(current.get('rarity','Common'),10)} spores!"
|
| 346 |
|
| 347 |
+
|
| 348 |
def _fallback_study(current: dict) -> str:
|
| 349 |
return f"I studied it carefully. Magic field updated. The clue: {RARITY_CLUES.get(current.get('rarity','Common'), '')}"
|
| 350 |
|
| 351 |
+
|
| 352 |
def _fallback_collect(current: dict) -> str:
|
| 353 |
return f"Added {current.get('name','it')} to the MycoDex! The pages feel warmer."
|
| 354 |
|
| 355 |
+
|
| 356 |
def _fallback_whisper(current: dict) -> str:
|
| 357 |
return "I followed the whisper... and remembered a path I've never walked. The mystery deepens."
|
| 358 |
|
| 359 |
+
|
| 360 |
def _fallback_chat(current: dict | None) -> str:
|
| 361 |
if current:
|
| 362 |
return f"I feel something strange about {current.get('name','this')}... stay close to me."
|
| 363 |
return "The forest is full of secrets. Move to a clearing and search — I'll watch for danger."
|
| 364 |
|
| 365 |
+
|
| 366 |
+
# ---------------------------------------------------------------------------
|
| 367 |
+
# Mushroom helpers
|
| 368 |
+
# ---------------------------------------------------------------------------
|
| 369 |
def _choose_mushroom(catalog=None):
|
| 370 |
mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
|
| 371 |
weights = [RARITY_WEIGHTS.get(m.rarity, 12) for m in mushrooms]
|
| 372 |
return random.choices(mushrooms, weights=weights, k=1)[0]
|
| 373 |
|
| 374 |
+
|
| 375 |
def _is_poisonous(current: dict) -> bool:
|
| 376 |
return current.get("name", "") in POISONOUS or current.get("danger") == "Poisonous"
|
| 377 |
|
| 378 |
+
|
| 379 |
def _score_value(current: dict) -> int:
|
| 380 |
return RARITY_SCORE.get(current.get("rarity", "Common"), 10)
|
| 381 |
|
| 382 |
+
|
| 383 |
def _score_collection(collection: list) -> int:
|
| 384 |
total = 0
|
| 385 |
for e in collection:
|
|
|
|
| 388 |
total += int(e.get("score_delta") or _score_value(e))
|
| 389 |
return max(0, total)
|
| 390 |
|
| 391 |
+
|
| 392 |
def _health(current: dict | None, collection: list) -> int:
|
| 393 |
if current and current.get("game_over") == "Yes":
|
| 394 |
return 0
|
| 395 |
deaths = sum(1 for e in collection if e.get("game_over") == "Yes")
|
| 396 |
return max(0, PLAYER_HEALTH - deaths)
|
| 397 |
|
| 398 |
+
|
| 399 |
def _mystery_state(count: int) -> dict:
|
| 400 |
chapter, next_ch = MYSTERY_CHAPTERS[0], None
|
| 401 |
for c in MYSTERY_CHAPTERS:
|
|
|
|
| 413 |
"mystery_next": next_line,
|
| 414 |
}
|
| 415 |
|
| 416 |
+
|
| 417 |
def _story_event(count: int) -> dict:
|
| 418 |
return FOREST_EVENTS[count % len(FOREST_EVENTS)]
|
| 419 |
|
| 420 |
+
|
| 421 |
def _build_current(mushroom, collection: list) -> dict:
|
| 422 |
count = len(collection)
|
| 423 |
current = mushroom.to_dict()
|
|
|
|
| 438 |
current.update(_mystery_state(count))
|
| 439 |
return current
|
| 440 |
|
| 441 |
+
|
| 442 |
def _append(history: list, role: str, content: str) -> list:
|
| 443 |
return [*history, {"role": role, "content": content}]
|
| 444 |
|
| 445 |
+
|
| 446 |
def _safe_history(h) -> list:
|
| 447 |
return list(h or welcome_history())
|
| 448 |
|
| 449 |
+
|
| 450 |
def _safe_collection(c) -> list:
|
| 451 |
return list(c or [])
|
| 452 |
|
| 453 |
+
|
| 454 |
# ---------------------------------------------------------------------------
|
| 455 |
+
# Public game actions
|
| 456 |
# ---------------------------------------------------------------------------
|
| 457 |
def discover_mushroom(collection=None, catalog=None):
|
| 458 |
+
"""Discover a new mushroom. LLM narrates the moment."""
|
| 459 |
coll = _safe_collection(collection)
|
| 460 |
mushroom = _choose_mushroom(catalog)
|
| 461 |
current = _build_current(mushroom, coll)
|
|
|
|
| 475 |
history = _append(welcome_history(), "assistant", reply)
|
| 476 |
return mushroom, current, history
|
| 477 |
|
| 478 |
+
|
| 479 |
def myco_reply(message=None, history=None, current=None, collection=None, position=None):
|
| 480 |
+
"""Player chats with Myco. LLM responds in character with full context."""
|
| 481 |
+
print("MYCO_REPLY CALLED, message:", repr(message))
|
| 482 |
hist = _safe_history(history)
|
| 483 |
coll = _safe_collection(collection)
|
| 484 |
clean = (message or "").strip()
|
|
|
|
| 492 |
|
| 493 |
return "", _append(hist, "user", clean) + [{"role": "assistant", "content": reply}]
|
| 494 |
|
| 495 |
+
|
| 496 |
companion_reply = myco_reply
|
| 497 |
|
| 498 |
+
|
| 499 |
def collect_current(current=None, collection=None, history=None):
|
| 500 |
+
"""Collect mushroom into MycoDex. LLM narrates the entry."""
|
| 501 |
coll = _safe_collection(collection)
|
| 502 |
hist = _safe_history(history)
|
| 503 |
|
|
|
|
| 528 |
reply = _llm(prompt, ctx) or _fallback_collect(current)
|
| 529 |
return updated_coll, _append(hist, "assistant", reply)
|
| 530 |
|
| 531 |
+
|
| 532 |
def pick_current(current=None, collection=None, history=None):
|
| 533 |
+
"""Pick mushroom as game item. Poison = game over. LLM narrates dramatically."""
|
| 534 |
coll = _safe_collection(collection)
|
| 535 |
hist = _safe_history(history)
|
| 536 |
|
|
|
|
| 582 |
reply = _llm(prompt, ctx) or _fallback_pick(current)
|
| 583 |
return updated_coll, picked, _append(hist, "assistant", f"🍄 {reply}")
|
| 584 |
|
| 585 |
+
|
| 586 |
def follow_whisper(current=None, collection=None, history=None):
|
| 587 |
+
"""Follow the forest whisper. LLM reveals mystery fragments."""
|
| 588 |
coll = _safe_collection(collection)
|
| 589 |
hist = _safe_history(history)
|
| 590 |
|
|
|
|
| 620 |
revealed = {**current, **mystery, "whisper_followed": "Yes"}
|
| 621 |
return revealed, _append(hist, "assistant", f"🌌 {reply}")
|
| 622 |
|
| 623 |
+
|
| 624 |
def study_current(current=None, history=None):
|
| 625 |
+
"""Study mushroom. LLM gives a careful field observation."""
|
| 626 |
hist = _safe_history(history)
|
| 627 |
|
| 628 |
if current is None:
|
|
|
|
| 633 |
"Provide a concise field observation and one hint about its magic or danger."
|
| 634 |
)
|
| 635 |
reply = _llm(prompt, _ctx(current, [])) or _fallback_study(current)
|
| 636 |
+
return reply, _append(hist, "assistant", reply)
|
|
|
|
|
|
|
|
|
|
| 637 |
|
|
|
|
|
|
|
|
|
|
| 638 |
|
| 639 |
+
def eat_current(current=None, collection=None, history=None):
|
| 640 |
+
"""Eating mushrooms raw is always blocked."""
|
| 641 |
+
hist = _safe_history(history)
|
| 642 |
if current is None:
|
| 643 |
+
return collection, _append(hist, "assistant", "There's nothing here to eat!")
|
| 644 |
+
reply = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
f"No no no! {current.get('name','That')} could be dangerous raw! "
|
| 646 |
"I'll never let you eat an unidentified mushroom. Study it first!"
|
| 647 |
)
|
| 648 |
+
return collection, _append(hist, "assistant", reply)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|