Spaces:
Sleeping
Sleeping
Deploy Myco from CI
Browse files- game/engine.py +251 -406
game/engine.py
CHANGED
|
@@ -1,35 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
โโโโโโโโโโโโโโโโโโโโโ
|
| 4 |
-
_get_model_and_tokenizer() Lazy singleton loader. Runs once on CPU with
|
| 5 |
-
bfloat16; ZeroGPU migrates weights at call time.
|
| 6 |
-
_run_pipeline() @spaces.GPU entry-point. Only this function
|
| 7 |
-
touches the GPU โ never call model.to("cuda")
|
| 8 |
-
anywhere else.
|
| 9 |
-
_llm() Single-turn game-event call. Appends
|
| 10 |
-
JSON_PROMPT_SUFFIX so structured action data
|
| 11 |
-
comes back reliably.
|
| 12 |
-
_llm_with_history() Multi-turn chat call. No JSON forcing โ Myco
|
| 13 |
-
speaks naturally and ends with MOOD/EMOTION tags.
|
| 14 |
-
_parse_llm_output() Splits every LLM reply into
|
| 15 |
-
(visible_text, scene_mood, myco_emotion).
|
| 16 |
-
engine.py stamps these onto `current` so
|
| 17 |
-
renders.py can change the forest visuals without
|
| 18 |
-
any extra Gradio state.
|
| 19 |
-
Scene signal contract
|
| 20 |
-
โโโโโโโโโโโโโโโโโโโโโ
|
| 21 |
-
Every public action returns `current` with two extra keys:
|
| 22 |
-
current["scene_mood"] โ one of: normal danger legendary rare
|
| 23 |
-
excited afraid wonder
|
| 24 |
-
current["myco_emotion"] โ one of: curious excited nervous afraid
|
| 25 |
-
wonder proud sad
|
| 26 |
-
renders.py reads these to pick background gradients, Myco's CSS animation,
|
| 27 |
-
and drop-shadow filter. Fallbacks always supply safe defaults so the UI
|
| 28 |
-
never breaks when the model is unavailable.
|
| 29 |
-
Fallback chain
|
| 30 |
-
โโโโโโโโโโโโโโ
|
| 31 |
-
LLM unavailable / exception โ _fallback_*() returns (text, mood, emotion)
|
| 32 |
-
so the game stays fully playable on CPU-only / cold-start environments.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import os
|
|
@@ -38,132 +8,126 @@ import threading
|
|
| 38 |
import re
|
| 39 |
import json
|
| 40 |
import traceback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
import torch
|
| 42 |
import spaces
|
| 43 |
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 44 |
-
from game.catalog import load_mushrooms
|
| 45 |
-
from game.state import collection_contains, mushroom_from_state, welcome_history
|
| 46 |
|
| 47 |
-
# โโ Model config โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 48 |
DEFAULT_MODEL_ID = "google/gemma-3-1b-it"
|
| 49 |
|
| 50 |
-
# Global singletons
|
| 51 |
-
_model
|
| 52 |
_tokenizer = None
|
| 53 |
-
_lock
|
| 54 |
|
| 55 |
def _get_model_and_tokenizer():
|
| 56 |
-
"""
|
| 57 |
-
Double-checked singleton loader.
|
| 58 |
-
Loads on CPU with bfloat16 and device_map=None so ZeroGPU can hook the
|
| 59 |
-
model safely before migrating weights to the GPU inside _run_pipeline().
|
| 60 |
-
"""
|
| 61 |
global _model, _tokenizer
|
| 62 |
if _model is not None and _tokenizer is not None:
|
| 63 |
return _model, _tokenizer
|
|
|
|
| 64 |
with _lock:
|
| 65 |
-
# Second check inside the lock to avoid a race on first load.
|
| 66 |
if _model is not None and _tokenizer is not None:
|
| 67 |
return _model, _tokenizer
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
| 70 |
try:
|
| 71 |
-
print(f"\n[Myco] Loading model and tokenizer
|
|
|
|
|
|
|
| 72 |
_tokenizer = AutoTokenizer.from_pretrained(model_id, token=token)
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
model_id,
|
| 75 |
token=token,
|
| 76 |
torch_dtype=torch.bfloat16,
|
| 77 |
-
device_map=None,
|
| 78 |
-
trust_remote_code=True
|
| 79 |
)
|
| 80 |
-
print(f"[Myco]
|
| 81 |
return _model, _tokenizer
|
| 82 |
except Exception as exc:
|
| 83 |
print(f"[Myco] Load error: {exc}")
|
| 84 |
-
traceback.print_exc()
|
| 85 |
return None, None
|
| 86 |
|
|
|
|
|
|
|
|
|
|
| 87 |
def _get_pipeline():
|
|
|
|
|
|
|
| 88 |
"""
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
#
|
|
|
|
| 97 |
@spaces.GPU
|
| 98 |
def _run_pipeline(pipe_ignored, messages):
|
| 99 |
-
|
| 100 |
-
The ONLY function that executes on the GPU.
|
| 101 |
-
ZeroGPU's @spaces.GPU decorator migrates model weights here automatically โ
|
| 102 |
-
never call model.to("cuda") manually anywhere else in this file.
|
| 103 |
-
Returns the raw decoded string (new tokens only, special tokens stripped).
|
| 104 |
-
"""
|
| 105 |
model, tokenizer = _get_model_and_tokenizer()
|
| 106 |
if model is None or tokenizer is None:
|
| 107 |
return "The forest is silent. (Model loading failed)"
|
| 108 |
-
|
| 109 |
-
#
|
|
|
|
|
|
|
|
|
|
| 110 |
formatted_prompt = tokenizer.apply_chat_template(
|
| 111 |
-
messages,
|
| 112 |
-
tokenize=False,
|
| 113 |
-
add_generation_prompt=True
|
| 114 |
)
|
| 115 |
-
|
| 116 |
-
#
|
| 117 |
inputs = tokenizer(formatted_prompt, return_tensors="pt").to(model.device)
|
|
|
|
|
|
|
| 118 |
with torch.no_grad():
|
| 119 |
outputs = model.generate(
|
| 120 |
**inputs,
|
| 121 |
max_new_tokens=256,
|
| 122 |
do_sample=True,
|
| 123 |
temperature=0.7,
|
| 124 |
-
pad_token_id=tokenizer.eos_token_id
|
| 125 |
)
|
| 126 |
-
|
| 127 |
-
#
|
| 128 |
-
input_length
|
| 129 |
generated_tokens = outputs[0][input_length:]
|
|
|
|
| 130 |
return tokenizer.decode(generated_tokens, skip_special_tokens=True)
|
| 131 |
|
| 132 |
-
|
| 133 |
-
# Appended to every single-turn game-event prompt so structured JSON comes back
|
| 134 |
-
# reliably even from small models that don't always follow instructions.
|
| 135 |
JSON_PROMPT_SUFFIX = (
|
| 136 |
"\n\nRespond with a single JSON object only. No prose before or after it. "
|
| 137 |
'Example: {"action":"pick","target":"Ruby Knuckle","thought":"It seems safe."}'
|
| 138 |
)
|
| 139 |
|
| 140 |
-
# โโ Game constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 141 |
RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
|
| 142 |
RARITY_SCORE = {"Common": 10, "Rare": 35, "Legendary": 100}
|
| 143 |
PLAYER_HEALTH = 3
|
| 144 |
POISON_PENALTY = -25
|
| 145 |
POISONOUS = {"Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle"}
|
| 146 |
|
| 147 |
-
# โโ System prompt โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 148 |
SYSTEM_PROMPT = """You are Myco, a tiny sentient mushroom companion and forest guide.
|
| 149 |
You are curious, warm, slightly anxious about poisonous mushrooms, and deeply connected
|
| 150 |
to the forest mystery. You speak in short, vivid sentences. You never break character.
|
| 151 |
You react emotionally to discoveries โ with awe for Legendary mushrooms, caution for
|
| 152 |
poisonous ones, and gentle wonder for Common ones. You hint at the deeper mystery of
|
| 153 |
-
the vanished forest and the MycoDex that seems to remember things it shouldn't.
|
| 154 |
-
|
| 155 |
-
SCENE SIGNAL (required at the end of EVERY reply)
|
| 156 |
-
On its own line, write exactly:
|
| 157 |
-
MOOD:<mood> EMOTION:<emotion>
|
| 158 |
-
|
| 159 |
-
<mood> โ one of: normal danger legendary rare excited afraid wonder
|
| 160 |
-
<emotion> โ one of: curious excited nervous afraid wonder proud sad
|
| 161 |
-
|
| 162 |
-
This controls the forest visuals in real time. Never omit it.
|
| 163 |
-
Example final line:
|
| 164 |
-
MOOD:danger EMOTION:afraid"""
|
| 165 |
|
| 166 |
-
# โโ World / narrative data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 167 |
FOREST_EVENTS = [
|
| 168 |
{"title": "A Quiet Clearing", "emoji": "๐ฟ", "mood": "calm"},
|
| 169 |
{"title": "Wind Between Trees", "emoji": "๐ฏ๏ธ", "mood": "afraid"},
|
|
@@ -183,140 +147,55 @@ RARITY_CLUES = {
|
|
| 183 |
"Legendary": "The whole clearing goes quiet โ this mushroom hides part of the Elder Map.",
|
| 184 |
}
|
| 185 |
|
| 186 |
-
# โโ Scene-signal keyword maps โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 187 |
-
# Used by _parse_llm_output() as a fallback when the model forgets to write the
|
| 188 |
-
# MOOD/EMOTION line. Keyword order matters โ first match wins.
|
| 189 |
-
MOOD_KEYWORDS: dict[str, list[str]] = {
|
| 190 |
-
"danger": ["poison", "danger", "deadly", "toxic", "warning", "flee",
|
| 191 |
-
"lethal", "fatal", "poisonous", "run", "back away"],
|
| 192 |
-
"legendary": ["legendary", "elder", "ancient", "map fragment", "awe",
|
| 193 |
-
"impossible", "crown", "sacred", "silence"],
|
| 194 |
-
"rare": ["rare", "magic", "hum", "glow", "shimmer", "strange",
|
| 195 |
-
"silver spore", "faint magic"],
|
| 196 |
-
"excited": ["found", "picked", "collect", "safe", "score", "hooray",
|
| 197 |
-
"wonderful", "great", "yes!"],
|
| 198 |
-
"afraid": ["afraid", "scared", "nervous", "dark", "shadow",
|
| 199 |
-
"uneasy", "tremble", "shiver"],
|
| 200 |
-
"wonder": ["beautiful", "glowing", "dream", "soft", "gentle",
|
| 201 |
-
"serene", "extraordinary", "sparkling"],
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
EMOTION_KEYWORDS: dict[str, list[str]] = {
|
| 205 |
-
"afraid": ["poison", "danger", "run", "flee", "deadly", "back away"],
|
| 206 |
-
"wonder": ["legendary", "awe", "impossible", "sacred", "ancient"],
|
| 207 |
-
"excited": ["hooray", "great", "score", "picked", "safe", "wonderful"],
|
| 208 |
-
"nervous": ["nervous", "uneasy", "tremble", "shiver", "not sure"],
|
| 209 |
-
"sad": ["sorry", "sad", "lost", "game over", "collapse", "goodbye"],
|
| 210 |
-
"proud": ["proud", "strong", "brave", "victory", "warrior"],
|
| 211 |
-
}
|
| 212 |
|
| 213 |
-
#
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
Split a raw LLM reply into (visible_text, scene_mood, myco_emotion).
|
| 217 |
-
Primary path โ model wrote 'MOOD:x EMOTION:y' on the last line.
|
| 218 |
-
Fallback path โ scan the full text with MOOD_KEYWORDS / EMOTION_KEYWORDS.
|
| 219 |
-
Always returns safe defaults so renders.py never KeyErrors.
|
| 220 |
-
"""
|
| 221 |
-
if not raw:
|
| 222 |
-
return "", "normal", "curious"
|
| 223 |
-
|
| 224 |
-
mood = "normal"
|
| 225 |
-
emotion = "curious"
|
| 226 |
-
lines = raw.strip().splitlines()
|
| 227 |
-
last = lines[-1].strip() if lines else ""
|
| 228 |
-
|
| 229 |
-
if "MOOD:" in last and "EMOTION:" in last:
|
| 230 |
-
# Happy path โ model followed instructions.
|
| 231 |
-
for part in last.split():
|
| 232 |
-
if part.startswith("MOOD:"):
|
| 233 |
-
mood = part[5:].strip().lower()
|
| 234 |
-
elif part.startswith("EMOTION:"):
|
| 235 |
-
emotion = part[8:].strip().lower()
|
| 236 |
-
visible = "\n".join(lines[:-1]).strip()
|
| 237 |
-
else:
|
| 238 |
-
# Fallback โ keyword scan on the full text.
|
| 239 |
-
visible = raw.strip()
|
| 240 |
-
lower = visible.lower()
|
| 241 |
-
for m, keywords in MOOD_KEYWORDS.items():
|
| 242 |
-
if any(k in lower for k in keywords):
|
| 243 |
-
mood = m
|
| 244 |
-
break
|
| 245 |
-
for e, keywords in EMOTION_KEYWORDS.items():
|
| 246 |
-
if any(k in lower for k in keywords):
|
| 247 |
-
emotion = e
|
| 248 |
-
break
|
| 249 |
-
|
| 250 |
-
# Clamp to valid values so CSS dicts in renders.py never KeyError.
|
| 251 |
-
valid_moods = {"normal", "danger", "legendary", "rare", "excited", "afraid", "wonder"}
|
| 252 |
-
valid_emotions = {"curious", "excited", "nervous", "afraid", "wonder", "proud", "sad"}
|
| 253 |
-
|
| 254 |
-
if mood not in valid_moods:
|
| 255 |
-
mood = "normal"
|
| 256 |
-
if emotion not in valid_emotions:
|
| 257 |
-
emotion = "curious"
|
| 258 |
-
|
| 259 |
-
return visible, mood, emotion
|
| 260 |
-
|
| 261 |
-
def _apply_scene_signals(current: dict, mood: str | None, emotion: str | None) -> dict:
|
| 262 |
-
"""
|
| 263 |
-
Stamp mood and emotion onto a copy of `current`.
|
| 264 |
-
renders.py reads current["scene_mood"] and current["myco_emotion"] directly
|
| 265 |
-
to drive backgrounds, animations, and filters โ no extra Gradio state needed.
|
| 266 |
-
"""
|
| 267 |
-
updated = dict(current)
|
| 268 |
-
if mood:
|
| 269 |
-
updated["scene_mood"] = mood
|
| 270 |
-
if emotion:
|
| 271 |
-
updated["myco_emotion"] = emotion
|
| 272 |
-
return updated
|
| 273 |
-
|
| 274 |
-
# โโ JSON extraction โโโ๏ฟฝ๏ฟฝ๏ฟฝโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 275 |
def _extract_json_or_text(generated_text: str) -> str | None:
|
| 276 |
-
"""
|
| 277 |
-
Try to parse the first valid JSON object/array from the model output.
|
| 278 |
-
Falls back to returning the raw text if no JSON is found.
|
| 279 |
-
Used by _llm() for structured game-event calls.
|
| 280 |
-
"""
|
| 281 |
if not generated_text:
|
| 282 |
return None
|
| 283 |
text = str(generated_text).strip()
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
return text or None
|
| 291 |
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
| 294 |
return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
pipe = _get_pipeline()
|
| 299 |
model = companion_model_id()
|
| 300 |
if pipe:
|
| 301 |
return f"๐ง Myco AI active ({model})"
|
| 302 |
return f"โ ๏ธ Myco AI fallback mode ({model} failed)"
|
| 303 |
|
| 304 |
-
|
|
|
|
| 305 |
return companion_status()
|
| 306 |
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
Returns (reply_text, scene_mood, myco_emotion) on success,
|
| 313 |
-
(None, None, None) when the model is unavailable or errors.
|
| 314 |
-
"""
|
| 315 |
pipe = _get_pipeline()
|
| 316 |
if not pipe:
|
| 317 |
-
return None
|
| 318 |
-
|
| 319 |
-
ctx
|
| 320 |
mushroom_line = ""
|
| 321 |
if ctx.get("name"):
|
| 322 |
poison_flag = " โ ๏ธ POISONOUS" if ctx.get("name") in POISONOUS else ""
|
|
@@ -326,48 +205,51 @@ def _llm(prompt: str, context: dict | None = None) -> tuple[str, str, str] | tup
|
|
| 326 |
f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}. "
|
| 327 |
f"Danger: {ctx.get('danger','Unknown')}."
|
| 328 |
)
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
f"Health: {ctx.get('health', 3)}/3."
|
| 337 |
-
)
|
| 338 |
-
|
| 339 |
messages = [
|
| 340 |
{"role": "system", "content": system},
|
| 341 |
{"role": "user", "content": prompt + JSON_PROMPT_SUFFIX},
|
| 342 |
]
|
| 343 |
-
|
| 344 |
try:
|
| 345 |
-
|
| 346 |
print("========== MYCO OUTPUT ==========")
|
| 347 |
-
print(
|
| 348 |
print("=================================")
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
except Exception as exc:
|
| 352 |
print(f"[Myco] Inference error: {exc}")
|
| 353 |
traceback.print_exc()
|
| 354 |
-
return None
|
| 355 |
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
No JSON forcing here โ Myco speaks naturally. The model is instructed via
|
| 362 |
-
SYSTEM_PROMPT to end every reply with 'MOOD:x EMOTION:y' so renders.py
|
| 363 |
-
can react to the conversation in real time.
|
| 364 |
-
Keeps the last 6 turns to stay within small-model context limits.
|
| 365 |
-
Returns (reply_text, scene_mood, myco_emotion) or (None, None, None).
|
| 366 |
-
"""
|
| 367 |
pipe = _get_pipeline()
|
| 368 |
if not pipe:
|
| 369 |
-
return None
|
| 370 |
-
|
| 371 |
ctx = context or {}
|
| 372 |
mushroom_line = ""
|
| 373 |
if ctx.get("name"):
|
|
@@ -377,7 +259,7 @@ def _llm_with_history(
|
|
| 377 |
f"Lore: {ctx.get('lore','?')}. "
|
| 378 |
f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}."
|
| 379 |
)
|
| 380 |
-
|
| 381 |
system = (
|
| 382 |
f"{SYSTEM_PROMPT}\n\n"
|
| 383 |
f"{mushroom_line}\n"
|
|
@@ -385,34 +267,35 @@ def _llm_with_history(
|
|
| 385 |
f"Mystery: {ctx.get('mystery_title', 'The Wrong Memory')}. "
|
| 386 |
f"Score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
|
| 387 |
)
|
| 388 |
-
|
| 389 |
messages = [{"role": "system", "content": system}]
|
| 390 |
for entry in history[-6:]:
|
| 391 |
role = entry.get("role", "assistant")
|
| 392 |
content = entry.get("content", "")
|
| 393 |
if isinstance(content, str) and content.strip():
|
| 394 |
messages.append({"role": role, "content": content})
|
| 395 |
-
|
| 396 |
messages.append({"role": "user", "content": user_message})
|
| 397 |
-
|
| 398 |
try:
|
| 399 |
-
|
|
|
|
| 400 |
print("========== MYCO OUTPUT ==========")
|
| 401 |
-
print(
|
| 402 |
print("=================================")
|
| 403 |
-
|
|
|
|
|
|
|
| 404 |
except Exception as exc:
|
| 405 |
print(f"[Myco] Inference error: {exc}")
|
| 406 |
traceback.print_exc()
|
| 407 |
-
return None
|
| 408 |
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
| 410 |
def _ctx(current: dict | None, collection: list) -> dict:
|
| 411 |
-
"""
|
| 412 |
-
Assemble the per-call context snapshot passed to every LLM call.
|
| 413 |
-
Includes current mushroom details, MycoDex size, active mystery chapter,
|
| 414 |
-
score, and health โ the model's complete view of the game state.
|
| 415 |
-
"""
|
| 416 |
count = len(collection)
|
| 417 |
chapter = MYSTERY_CHAPTERS[0]
|
| 418 |
for c in MYSTERY_CHAPTERS:
|
|
@@ -420,7 +303,6 @@ def _ctx(current: dict | None, collection: list) -> dict:
|
|
| 420 |
chapter = c
|
| 421 |
score = _score_collection(collection)
|
| 422 |
health = _health(current, collection)
|
| 423 |
-
|
| 424 |
ctx: dict = {
|
| 425 |
"collection_count": count,
|
| 426 |
"mystery_title": chapter["title"],
|
|
@@ -428,7 +310,6 @@ def _ctx(current: dict | None, collection: list) -> dict:
|
|
| 428 |
"score": score,
|
| 429 |
"health": health,
|
| 430 |
}
|
| 431 |
-
|
| 432 |
if current:
|
| 433 |
ctx.update({
|
| 434 |
"name": current.get("name", ""),
|
|
@@ -441,74 +322,65 @@ def _ctx(current: dict | None, collection: list) -> dict:
|
|
| 441 |
})
|
| 442 |
return ctx
|
| 443 |
|
| 444 |
-
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
| 446 |
name = current.get("name", "something")
|
| 447 |
rarity = current.get("rarity", "Common")
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
"danger", "afraid",
|
| 452 |
-
)
|
| 453 |
if rarity == "Legendary":
|
| 454 |
-
return
|
| 455 |
-
f"Oh! Oh! A {name}! The whole clearing just went silent. This is from the Elder Map!",
|
| 456 |
-
"legendary", "wonder",
|
| 457 |
-
)
|
| 458 |
if rarity == "Rare":
|
| 459 |
-
return
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
)
|
| 463 |
-
return (
|
| 464 |
-
f"A {name}! Found near {current.get('habitat','the forest')}. Let me sense it first.",
|
| 465 |
-
"normal", "curious", )
|
| 466 |
|
| 467 |
-
def _fallback_pick(current: dict) ->
|
| 468 |
if _is_poisonous(current):
|
| 469 |
-
return "๐ That was poisonous! I tried to stop you... the forest goes dark."
|
| 470 |
-
|
| 471 |
-
return f"Got {current.get('name','it')}! +{score} spores!", "excited", "excited"
|
| 472 |
|
| 473 |
-
def _fallback_study(current: dict) -> tuple[str, str, str]:
|
| 474 |
-
clue = RARITY_CLUES.get(current.get("rarity", "Common"), "")
|
| 475 |
-
return f"I studied it carefully. {clue}", "normal", "curious"
|
| 476 |
|
| 477 |
-
def
|
| 478 |
-
return f"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
-
def _fallback_whisper(current: dict) -> tuple[str, str, str]:
|
| 481 |
-
return (
|
| 482 |
-
"I followed the whisper... and remembered a path I've never walked. The mystery deepens.",
|
| 483 |
-
"wonder", "wonder",
|
| 484 |
-
)
|
| 485 |
|
| 486 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
if current:
|
| 488 |
-
return (
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
)
|
| 492 |
-
return (
|
| 493 |
-
"The forest is full of secrets. Move to a clearing and search โ I'll watch for danger.",
|
| 494 |
-
"normal", "curious",
|
| 495 |
-
)
|
| 496 |
|
| 497 |
-
#
|
|
|
|
|
|
|
| 498 |
def _choose_mushroom(catalog=None):
|
| 499 |
-
"""Weighted random pick โ Legendary 8 %, Rare 24 %, Common 64 %."""
|
| 500 |
mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
|
| 501 |
weights = [RARITY_WEIGHTS.get(m.rarity, 12) for m in mushrooms]
|
| 502 |
return random.choices(mushrooms, weights=weights, k=1)[0]
|
| 503 |
|
|
|
|
| 504 |
def _is_poisonous(current: dict) -> bool:
|
| 505 |
return current.get("name", "") in POISONOUS or current.get("danger") == "Poisonous"
|
| 506 |
|
|
|
|
| 507 |
def _score_value(current: dict) -> int:
|
| 508 |
return RARITY_SCORE.get(current.get("rarity", "Common"), 10)
|
| 509 |
|
|
|
|
| 510 |
def _score_collection(collection: list) -> int:
|
| 511 |
-
"""Sum score_delta for all non-game-over entries."""
|
| 512 |
total = 0
|
| 513 |
for e in collection:
|
| 514 |
if e.get("game_over") == "Yes":
|
|
@@ -516,19 +388,15 @@ def _score_collection(collection: list) -> int:
|
|
| 516 |
total += int(e.get("score_delta") or _score_value(e))
|
| 517 |
return max(0, total)
|
| 518 |
|
|
|
|
| 519 |
def _health(current: dict | None, collection: list) -> int:
|
| 520 |
-
"""One health lost per game-over entry; floor at 0."""
|
| 521 |
if current and current.get("game_over") == "Yes":
|
| 522 |
return 0
|
| 523 |
deaths = sum(1 for e in collection if e.get("game_over") == "Yes")
|
| 524 |
return max(0, PLAYER_HEALTH - deaths)
|
| 525 |
|
|
|
|
| 526 |
def _mystery_state(count: int) -> dict:
|
| 527 |
-
"""
|
| 528 |
-
Return the active mystery chapter and the next-chapter teaser line.
|
| 529 |
-
Both are stamped onto `current` so the HUD and story box always reflect
|
| 530 |
-
the player's narrative progress.
|
| 531 |
-
"""
|
| 532 |
chapter, next_ch = MYSTERY_CHAPTERS[0], None
|
| 533 |
for c in MYSTERY_CHAPTERS:
|
| 534 |
if count >= c["threshold"]:
|
|
@@ -545,16 +413,12 @@ def _mystery_state(count: int) -> dict:
|
|
| 545 |
"mystery_next": next_line,
|
| 546 |
}
|
| 547 |
|
|
|
|
| 548 |
def _story_event(count: int) -> dict:
|
| 549 |
-
"""Cycle through FOREST_EVENTS by discovery count โ simple deterministic variety."""
|
| 550 |
return FOREST_EVENTS[count % len(FOREST_EVENTS)]
|
| 551 |
|
|
|
|
| 552 |
def _build_current(mushroom, collection: list) -> dict:
|
| 553 |
-
"""
|
| 554 |
-
Build the full `current` state dict for a freshly discovered mushroom.
|
| 555 |
-
Includes poison flag, score/health snapshot, rarity clue, forest event,
|
| 556 |
-
mystery chapter state, and default scene signals (overwritten by LLM below).
|
| 557 |
-
"""
|
| 558 |
count = len(collection)
|
| 559 |
current = mushroom.to_dict()
|
| 560 |
current["poison"] = "Yes" if mushroom.name in POISONOUS else "No"
|
|
@@ -562,44 +426,41 @@ def _build_current(mushroom, collection: list) -> dict:
|
|
| 562 |
current["health"] = str(_health(current, collection))
|
| 563 |
current["score_delta"] = "0"
|
| 564 |
current["clue"] = RARITY_CLUES.get(mushroom.rarity, RARITY_CLUES["Common"])
|
| 565 |
-
|
| 566 |
if count == 0:
|
| 567 |
current["clue"] = f"First clue: {mushroom.name} marks the beginning of the Spore Door trail."
|
| 568 |
-
|
| 569 |
event = _story_event(count)
|
| 570 |
current.update({
|
| 571 |
-
"event_title":
|
| 572 |
-
"event_emoji":
|
| 573 |
-
"myco_mood":
|
| 574 |
-
"reward_text":
|
| 575 |
-
# Default scene signals โ overwritten after every LLM call.
|
| 576 |
-
"scene_mood": "normal",
|
| 577 |
-
"myco_emotion": "curious",
|
| 578 |
})
|
| 579 |
current.update(_mystery_state(count))
|
| 580 |
return current
|
| 581 |
|
|
|
|
| 582 |
def _append(history: list, role: str, content: str) -> list:
|
| 583 |
return [*history, {"role": role, "content": content}]
|
| 584 |
|
|
|
|
| 585 |
def _safe_history(h) -> list:
|
| 586 |
return list(h or welcome_history())
|
| 587 |
|
|
|
|
| 588 |
def _safe_collection(c) -> list:
|
| 589 |
return list(c or [])
|
| 590 |
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
| 592 |
def discover_mushroom(collection=None, catalog=None):
|
| 593 |
-
"""
|
| 594 |
-
Spawn a weighted-random mushroom and ask the LLM to narrate the moment.
|
| 595 |
-
The LLM's mood/emotion tags set the initial forest scene colour and
|
| 596 |
-
Myco's animation for this discovery.
|
| 597 |
-
"""
|
| 598 |
coll = _safe_collection(collection)
|
| 599 |
mushroom = _choose_mushroom(catalog)
|
| 600 |
current = _build_current(mushroom, coll)
|
| 601 |
ctx = _ctx(current, coll)
|
| 602 |
-
|
| 603 |
prompt = (
|
| 604 |
f"The player just discovered a {mushroom.rarity} mushroom called {mushroom.name} "
|
| 605 |
f"near {mushroom.habitat}. "
|
|
@@ -610,55 +471,42 @@ def discover_mushroom(collection=None, catalog=None):
|
|
| 610 |
f"Mystery chapter: {current['mystery_title']}. "
|
| 611 |
"React in character. Hint at what to do next (Study, Pick, Follow Whisper, or Collect)."
|
| 612 |
)
|
| 613 |
-
reply
|
| 614 |
-
if reply is None:
|
| 615 |
-
reply, mood, emotion = _fallback_discover(current)
|
| 616 |
-
|
| 617 |
-
current = _apply_scene_signals(current, mood, emotion)
|
| 618 |
history = _append(welcome_history(), "assistant", reply)
|
| 619 |
return mushroom, current, history
|
| 620 |
|
|
|
|
| 621 |
def myco_reply(message=None, history=None, current=None, collection=None, position=None):
|
| 622 |
-
"""
|
| 623 |
-
Handle a player chat message.
|
| 624 |
-
The LLM reply updates scene_mood and myco_emotion on `current` so the
|
| 625 |
-
forest card reacts to the conversation without a separate Search action.
|
| 626 |
-
Returns ("", updated_history, updated_current) matching Gradio output slots.
|
| 627 |
-
"""
|
| 628 |
print("MYCO_REPLY CALLED, message:", repr(message))
|
| 629 |
hist = _safe_history(history)
|
| 630 |
coll = _safe_collection(collection)
|
| 631 |
clean = (message or "").strip()
|
| 632 |
if not clean:
|
| 633 |
-
return "", hist
|
| 634 |
-
|
| 635 |
ctx = _ctx(current, coll)
|
| 636 |
-
reply
|
| 637 |
-
if
|
| 638 |
-
reply
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
current = _apply_scene_signals(current, mood, emotion)
|
| 643 |
-
|
| 644 |
-
new_history = _append(hist, "user", clean) + [{"role": "assistant", "content": reply}]
|
| 645 |
-
return "", new_history, current
|
| 646 |
|
| 647 |
companion_reply = myco_reply
|
| 648 |
|
|
|
|
| 649 |
def collect_current(current=None, collection=None, history=None):
|
| 650 |
-
"""
|
| 651 |
-
Add the current mushroom to the MycoDex.
|
| 652 |
-
LLM celebrates the entry and may drop a mystery hint.
|
| 653 |
-
Duplicate entries are silently rejected.
|
| 654 |
-
"""
|
| 655 |
coll = _safe_collection(collection)
|
| 656 |
hist = _safe_history(history)
|
|
|
|
| 657 |
if current is None:
|
| 658 |
-
return coll,
|
|
|
|
| 659 |
if collection_contains(coll, current["name"]):
|
| 660 |
-
return coll,
|
| 661 |
-
|
| 662 |
score_delta = _score_value(current)
|
| 663 |
score_total = _score_collection(coll) + score_delta
|
| 664 |
collected = {
|
|
@@ -670,33 +518,27 @@ def collect_current(current=None, collection=None, history=None):
|
|
| 670 |
}
|
| 671 |
updated_coll = [*coll, collected]
|
| 672 |
ctx = _ctx(current, coll)
|
| 673 |
-
|
| 674 |
prompt = (
|
| 675 |
f"The player just added {current['name']} ({current.get('rarity','Common')}) to the MycoDex! "
|
| 676 |
f"+{score_delta} spores. Total score: {score_total}. "
|
| 677 |
f"MycoDex now has {len(updated_coll)} entries. "
|
| 678 |
"Celebrate this moment. Add a small lore detail or mystery hint."
|
| 679 |
)
|
| 680 |
-
reply
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
collected = _apply_scene_signals(collected, mood, emotion)
|
| 685 |
-
return updated_coll, collected, _append(hist, "assistant", reply)
|
| 686 |
|
| 687 |
def pick_current(current=None, collection=None, history=None):
|
| 688 |
-
"""
|
| 689 |
-
Pick the current mushroom.
|
| 690 |
-
Poisonous pick โ game over, health 0, score penalty, dark scene.
|
| 691 |
-
Safe pick โ score awarded, mushroom flies off screen (CSS handled
|
| 692 |
-
by state-picked class in renders.py).
|
| 693 |
-
"""
|
| 694 |
coll = _safe_collection(collection)
|
| 695 |
hist = _safe_history(history)
|
|
|
|
| 696 |
if current is None:
|
| 697 |
return coll, None, _append(hist, "assistant", "Find a mushroom first before picking!")
|
| 698 |
-
|
| 699 |
ctx = _ctx(current, coll)
|
|
|
|
| 700 |
if _is_poisonous(current):
|
| 701 |
score_total = max(0, _score_collection(coll) + POISON_PENALTY)
|
| 702 |
game_over = {
|
|
@@ -707,21 +549,15 @@ def pick_current(current=None, collection=None, history=None):
|
|
| 707 |
"score_delta": str(POISON_PENALTY),
|
| 708 |
"score_total": str(score_total),
|
| 709 |
"reward_text": f"Poison! {POISON_PENALTY} spores ยท Game Over",
|
| 710 |
-
"scene_mood": "danger",
|
| 711 |
-
"myco_emotion":"sad",
|
| 712 |
}
|
| 713 |
prompt = (
|
| 714 |
f"DRAMATIC MOMENT: The player picked {current['name']} which is POISONOUS! "
|
| 715 |
f"Game Over! Score drops by 25 to {score_total}. Health โ 0. "
|
| 716 |
"React with shock, sadness, and a dramatic farewell. Make it memorable."
|
| 717 |
)
|
| 718 |
-
reply
|
| 719 |
-
if reply is None:
|
| 720 |
-
reply, mood, emotion = _fallback_pick(current)
|
| 721 |
-
game_over = _apply_scene_signals(game_over, mood or "danger", emotion or "sad")
|
| 722 |
return coll, game_over, _append(hist, "assistant", f"๐ {reply}")
|
| 723 |
-
|
| 724 |
-
# Safe pick
|
| 725 |
score_delta = _score_value(current)
|
| 726 |
score_total = _score_collection(coll) + score_delta
|
| 727 |
picked = {
|
|
@@ -733,47 +569,57 @@ def pick_current(current=None, collection=None, history=None):
|
|
| 733 |
"health": str(_health(current, coll)),
|
| 734 |
"reward_text": f"+{score_delta} spores",
|
| 735 |
}
|
| 736 |
-
|
| 737 |
if collection_contains(coll, picked["name"]):
|
| 738 |
return coll, picked, _append(hist, "assistant", f"{picked['name']} already picked!")
|
| 739 |
-
|
| 740 |
updated_coll = [*coll, picked]
|
| 741 |
prompt = (
|
| 742 |
f"The player safely picked {current['name']} ({current.get('rarity','Common')})! "
|
| 743 |
f"+{score_delta} spores. Total: {score_total}. "
|
| 744 |
"Celebrate! Make it feel like a platformer power-up moment."
|
| 745 |
)
|
| 746 |
-
reply
|
| 747 |
-
if reply is None:
|
| 748 |
-
reply, mood, emotion = _fallback_pick(current)
|
| 749 |
-
picked = _apply_scene_signals(picked, mood, emotion)
|
| 750 |
return updated_coll, picked, _append(hist, "assistant", f"๐ {reply}")
|
| 751 |
|
|
|
|
| 752 |
def follow_whisper(current=None, collection=None, history=None):
|
| 753 |
-
"""
|
| 754 |
-
Follow the forest whisper.
|
| 755 |
-
If the current mushroom is poisonous, warning signs flare up.
|
| 756 |
-
Otherwise, the player uncovers details regarding the active mystery narrative chapter.
|
| 757 |
-
"""
|
| 758 |
coll = _safe_collection(collection)
|
| 759 |
hist = _safe_history(history)
|
|
|
|
| 760 |
if current is None:
|
| 761 |
-
return
|
| 762 |
-
|
|
|
|
| 763 |
ctx = _ctx(current, coll)
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
)
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
current = _apply_scene_signals(current, mood, emotion)
|
| 776 |
-
return coll, current, _append(hist, "assistant", f"โจ {reply}")
|
| 777 |
|
| 778 |
def study_current(current=None, history=None):
|
| 779 |
"""Study mushroom. LLM gives a careful field observation."""
|
|
@@ -801,4 +647,3 @@ def eat_current(current=None, collection=None, history=None):
|
|
| 801 |
)
|
| 802 |
return collection, _append(hist, "assistant", reply)
|
| 803 |
|
| 804 |
-
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core Myco gameplay โ LLM is the primary game engine.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import os
|
|
|
|
| 8 |
import re
|
| 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."}'
|
| 116 |
)
|
| 117 |
|
|
|
|
| 118 |
RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
|
| 119 |
RARITY_SCORE = {"Common": 10, "Rare": 35, "Legendary": 100}
|
| 120 |
PLAYER_HEALTH = 3
|
| 121 |
POISON_PENALTY = -25
|
| 122 |
POISONOUS = {"Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle"}
|
| 123 |
|
|
|
|
| 124 |
SYSTEM_PROMPT = """You are Myco, a tiny sentient mushroom companion and forest guide.
|
| 125 |
You are curious, warm, slightly anxious about poisonous mushrooms, and deeply connected
|
| 126 |
to the forest mystery. You speak in short, vivid sentences. You never break character.
|
| 127 |
You react emotionally to discoveries โ with awe for Legendary mushrooms, caution for
|
| 128 |
poisonous ones, and gentle wonder for Common ones. You hint at the deeper mystery of
|
| 129 |
+
the vanished forest and the MycoDex that seems to remember things it shouldn't."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
|
|
|
| 131 |
FOREST_EVENTS = [
|
| 132 |
{"title": "A Quiet Clearing", "emoji": "๐ฟ", "mood": "calm"},
|
| 133 |
{"title": "Wind Between Trees", "emoji": "๐ฏ๏ธ", "mood": "afraid"},
|
|
|
|
| 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:
|
| 156 |
return None
|
| 157 |
text = str(generated_text).strip()
|
| 158 |
+
|
| 159 |
+
simple_matches = re.findall(r"\{.*?\}|\[.*?\]", text, flags=re.DOTALL)
|
| 160 |
+
if simple_matches:
|
| 161 |
+
for candidate in reversed(simple_matches):
|
| 162 |
+
try:
|
| 163 |
+
parsed = json.loads(candidate)
|
| 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()
|
| 181 |
if pipe:
|
| 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()
|
| 195 |
if not pipe:
|
| 196 |
+
return None
|
| 197 |
+
|
| 198 |
+
ctx = context or {}
|
| 199 |
mushroom_line = ""
|
| 200 |
if ctx.get("name"):
|
| 201 |
poison_flag = " โ ๏ธ POISONOUS" if ctx.get("name") in POISONOUS else ""
|
|
|
|
| 205 |
f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}. "
|
| 206 |
f"Danger: {ctx.get('danger','Unknown')}."
|
| 207 |
)
|
| 208 |
+
|
| 209 |
+
collection_line = f"MycoDex entries: {ctx.get('collection_count', 0)}."
|
| 210 |
+
mystery_line = f"Active mystery chapter: {ctx.get('mystery_title', 'The Wrong Memory')}."
|
| 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)
|
| 229 |
+
else:
|
| 230 |
+
generated = str(outputs)
|
| 231 |
+
|
| 232 |
+
if isinstance(generated, list):
|
| 233 |
+
last = generated[-1]
|
| 234 |
+
text = last.get("content") if isinstance(last, dict) else str(last)
|
| 235 |
+
else:
|
| 236 |
+
text = str(generated)
|
| 237 |
+
|
| 238 |
+
return _extract_json_or_text(text)
|
| 239 |
except Exception as exc:
|
| 240 |
print(f"[Myco] Inference error: {exc}")
|
| 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:
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
ctx = context or {}
|
| 254 |
mushroom_line = ""
|
| 255 |
if ctx.get("name"):
|
|
|
|
| 259 |
f"Lore: {ctx.get('lore','?')}. "
|
| 260 |
f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}."
|
| 261 |
)
|
| 262 |
+
|
| 263 |
system = (
|
| 264 |
f"{SYSTEM_PROMPT}\n\n"
|
| 265 |
f"{mushroom_line}\n"
|
|
|
|
| 267 |
f"Mystery: {ctx.get('mystery_title', 'The Wrong Memory')}. "
|
| 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)
|
| 300 |
chapter = MYSTERY_CHAPTERS[0]
|
| 301 |
for c in MYSTERY_CHAPTERS:
|
|
|
|
| 303 |
chapter = c
|
| 304 |
score = _score_collection(collection)
|
| 305 |
health = _health(current, collection)
|
|
|
|
| 306 |
ctx: dict = {
|
| 307 |
"collection_count": count,
|
| 308 |
"mystery_title": chapter["title"],
|
|
|
|
| 310 |
"score": score,
|
| 311 |
"health": health,
|
| 312 |
}
|
|
|
|
| 313 |
if current:
|
| 314 |
ctx.update({
|
| 315 |
"name": current.get("name", ""),
|
|
|
|
| 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!"
|
|
|
|
|
|
|
|
|
|
| 337 |
if rarity == "Rare":
|
| 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:
|
| 386 |
if e.get("game_over") == "Yes":
|
|
|
|
| 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:
|
| 402 |
if count >= c["threshold"]:
|
|
|
|
| 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()
|
| 424 |
current["poison"] = "Yes" if mushroom.name in POISONOUS else "No"
|
|
|
|
| 426 |
current["health"] = str(_health(current, collection))
|
| 427 |
current["score_delta"] = "0"
|
| 428 |
current["clue"] = RARITY_CLUES.get(mushroom.rarity, RARITY_CLUES["Common"])
|
|
|
|
| 429 |
if count == 0:
|
| 430 |
current["clue"] = f"First clue: {mushroom.name} marks the beginning of the Spore Door trail."
|
|
|
|
| 431 |
event = _story_event(count)
|
| 432 |
current.update({
|
| 433 |
+
"event_title": event["title"],
|
| 434 |
+
"event_emoji": event["emoji"],
|
| 435 |
+
"myco_mood": event["mood"],
|
| 436 |
+
"reward_text": "Discover, then pick or collect.",
|
|
|
|
|
|
|
|
|
|
| 437 |
})
|
| 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)
|
| 462 |
ctx = _ctx(current, coll)
|
| 463 |
+
|
| 464 |
prompt = (
|
| 465 |
f"The player just discovered a {mushroom.rarity} mushroom called {mushroom.name} "
|
| 466 |
f"near {mushroom.habitat}. "
|
|
|
|
| 471 |
f"Mystery chapter: {current['mystery_title']}. "
|
| 472 |
"React in character. Hint at what to do next (Study, Pick, Follow Whisper, or Collect)."
|
| 473 |
)
|
| 474 |
+
reply = _llm(prompt, ctx) or _fallback_discover(current)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
| 485 |
if not clean:
|
| 486 |
+
return "", hist
|
| 487 |
+
|
| 488 |
ctx = _ctx(current, coll)
|
| 489 |
+
reply = _llm_with_history(hist, clean, ctx)
|
| 490 |
+
if not reply:
|
| 491 |
+
reply = _fallback_chat(current)
|
| 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 |
+
|
| 504 |
if current is None:
|
| 505 |
+
return coll, _append(hist, "assistant", "We need to find a mushroom first!")
|
| 506 |
+
|
| 507 |
if collection_contains(coll, current["name"]):
|
| 508 |
+
return coll, _append(hist, "assistant", f"{current['name']} is already in the MycoDex!")
|
| 509 |
+
|
| 510 |
score_delta = _score_value(current)
|
| 511 |
score_total = _score_collection(coll) + score_delta
|
| 512 |
collected = {
|
|
|
|
| 518 |
}
|
| 519 |
updated_coll = [*coll, collected]
|
| 520 |
ctx = _ctx(current, coll)
|
| 521 |
+
|
| 522 |
prompt = (
|
| 523 |
f"The player just added {current['name']} ({current.get('rarity','Common')}) to the MycoDex! "
|
| 524 |
f"+{score_delta} spores. Total score: {score_total}. "
|
| 525 |
f"MycoDex now has {len(updated_coll)} entries. "
|
| 526 |
"Celebrate this moment. Add a small lore detail or mystery hint."
|
| 527 |
)
|
| 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 |
+
|
| 537 |
if current is None:
|
| 538 |
return coll, None, _append(hist, "assistant", "Find a mushroom first before picking!")
|
| 539 |
+
|
| 540 |
ctx = _ctx(current, coll)
|
| 541 |
+
|
| 542 |
if _is_poisonous(current):
|
| 543 |
score_total = max(0, _score_collection(coll) + POISON_PENALTY)
|
| 544 |
game_over = {
|
|
|
|
| 549 |
"score_delta": str(POISON_PENALTY),
|
| 550 |
"score_total": str(score_total),
|
| 551 |
"reward_text": f"Poison! {POISON_PENALTY} spores ยท Game Over",
|
|
|
|
|
|
|
| 552 |
}
|
| 553 |
prompt = (
|
| 554 |
f"DRAMATIC MOMENT: The player picked {current['name']} which is POISONOUS! "
|
| 555 |
f"Game Over! Score drops by 25 to {score_total}. Health โ 0. "
|
| 556 |
"React with shock, sadness, and a dramatic farewell. Make it memorable."
|
| 557 |
)
|
| 558 |
+
reply = _llm(prompt, ctx) or _fallback_pick(current)
|
|
|
|
|
|
|
|
|
|
| 559 |
return coll, game_over, _append(hist, "assistant", f"๐ {reply}")
|
| 560 |
+
|
|
|
|
| 561 |
score_delta = _score_value(current)
|
| 562 |
score_total = _score_collection(coll) + score_delta
|
| 563 |
picked = {
|
|
|
|
| 569 |
"health": str(_health(current, coll)),
|
| 570 |
"reward_text": f"+{score_delta} spores",
|
| 571 |
}
|
| 572 |
+
|
| 573 |
if collection_contains(coll, picked["name"]):
|
| 574 |
return coll, picked, _append(hist, "assistant", f"{picked['name']} already picked!")
|
| 575 |
+
|
| 576 |
updated_coll = [*coll, picked]
|
| 577 |
prompt = (
|
| 578 |
f"The player safely picked {current['name']} ({current.get('rarity','Common')})! "
|
| 579 |
f"+{score_delta} spores. Total: {score_total}. "
|
| 580 |
"Celebrate! Make it feel like a platformer power-up moment."
|
| 581 |
)
|
| 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 |
+
|
| 591 |
if current is None:
|
| 592 |
+
return None, _append(hist, "assistant",
|
| 593 |
+
"Myco cups one ear. The forest only whispers near mushrooms โ search a clearing first.")
|
| 594 |
+
|
| 595 |
ctx = _ctx(current, coll)
|
| 596 |
+
|
| 597 |
+
if _is_poisonous(current) and current.get("studied") != "Yes":
|
| 598 |
+
game_over = {
|
| 599 |
+
**current,
|
| 600 |
+
"danger": "Poisonous",
|
| 601 |
+
"game_over": "Yes",
|
| 602 |
+
"health": "0",
|
| 603 |
+
"score_total": str(max(0, _score_collection(coll) + POISON_PENALTY)),
|
| 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."
|
| 608 |
+
)
|
| 609 |
+
reply = _llm(prompt, ctx) or "๐ The whisper belonged to poison... Myco screams."
|
| 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 |
+
"Reveal this mystery fragment dramatically. "
|
| 617 |
+
"Make Myco gasp or tremble. Hint that the MycoDex is alive and regrowing the lost forest."
|
| 618 |
)
|
| 619 |
+
reply = _llm(prompt, ctx) or _fallback_whisper(current)
|
| 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."""
|
|
|
|
| 647 |
)
|
| 648 |
return collection, _append(hist, "assistant", reply)
|
| 649 |
|
|
|