byte-vortex commited on
Commit
086d6fb
ยท
verified ยท
1 Parent(s): e72183b

Deploy Myco from CI

Browse files
Files changed (1) hide show
  1. game/engine.py +251 -406
game/engine.py CHANGED
@@ -1,35 +1,5 @@
1
- """Core Myco gameplay โ€” LLM is the primary game engine.
2
- Architecture overview
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 โ€” loaded once, reused across every GPU call.
51
- _model = None
52
  _tokenizer = None
53
- _lock = threading.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
- model_id = os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
69
- token = os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN")
 
 
70
  try:
71
- print(f"\n[Myco] Loading model and tokenizer: {model_id} โ€ฆ")
 
 
72
  _tokenizer = AutoTokenizer.from_pretrained(model_id, token=token)
73
- _model = AutoModelForCausalLM.from_pretrained(
 
 
74
  model_id,
75
  token=token,
76
  torch_dtype=torch.bfloat16,
77
- device_map=None, # CRITICAL โ€” let ZeroGPU own placement
78
- trust_remote_code=True,
79
  )
80
- print(f"[Myco] Loaded: {model_id}")
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
- Legacy-compatibility shim.
90
- Returns the model object (truthy) when loaded, None when not.
91
- Keeps companion_status() and any startup checks working without changes.
92
- """
93
- model, _ = _get_model_and_tokenizer()
94
- return model # None when loading failed
95
-
96
- # โ”€โ”€ GPU runner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
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
- # Build the chat template the model expects (adds BOS, role tokens, etc.)
 
 
 
110
  formatted_prompt = tokenizer.apply_chat_template(
111
- messages,
112
- tokenize=False,
113
- add_generation_prompt=True,
114
  )
115
-
116
- # Map inputs to whatever device ZeroGPU assigned the model weights to.
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
- # Slice off the prompt tokens โ€” return new content only.
128
- input_length = inputs.input_ids.shape[1]
129
  generated_tokens = outputs[0][input_length:]
 
130
  return tokenizer.decode(generated_tokens, skip_special_tokens=True)
131
 
132
- # โ”€โ”€ Prompt constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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
- # โ”€โ”€ LLM output parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
214
- def _parse_llm_output(raw: str) -> tuple[str, str, str]:
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
- for candidate in reversed(re.findall(r"\{.*?\}|\[.*?\]", text, flags=re.DOTALL)):
285
- try:
286
- parsed = json.loads(candidate)
287
- return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
288
- except Exception:
289
- continue
 
 
 
 
290
  return text or None
291
 
292
- # โ”€โ”€ Status helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
293
- def companion_model_id() -> str:
 
 
 
294
  return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
295
 
296
- def companion_status() -> str:
297
- """Returns a human-readable string shown in the Gradio status bar."""
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
- def hf_companion_status() -> str:
 
305
  return companion_status()
306
 
307
- # โ”€โ”€ LLM call โ€” single-turn game event โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
308
- def _llm(prompt: str, context: dict | None = None) -> tuple[str, str, str] | tuple[None, None, None]:
309
- """
310
- Single-turn call for game events (discover, pick, study, collect, whisper).
311
- Appends JSON_PROMPT_SUFFIX to encourage structured output from small models.
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, None, None
318
-
319
- ctx = context or {}
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
- system = (
331
- f"{SYSTEM_PROMPT}\n\n"
332
- f"{mushroom_line}\n"
333
- f"MycoDex entries: {ctx.get('collection_count', 0)}. "
334
- f"Active mystery chapter: {ctx.get('mystery_title', 'The Wrong Memory')}. "
335
- f"Player score: {ctx.get('score', 0)} spores. "
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
- raw = _run_pipeline(pipe, messages)
346
  print("========== MYCO OUTPUT ==========")
347
- print(raw)
348
  print("=================================")
349
- text = _extract_json_or_text(str(raw))
350
- return _parse_llm_output(text or "")
 
 
 
 
 
 
 
 
 
 
 
 
351
  except Exception as exc:
352
  print(f"[Myco] Inference error: {exc}")
353
  traceback.print_exc()
354
- return None, None, None
355
 
356
- # โ”€โ”€ LLM call โ€” multi-turn chat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
357
- def _llm_with_history(
358
- history: list, user_message: str, context: dict) -> tuple[str, str, str] | tuple[None, None, None]:
359
- """
360
- Multi-turn call for the Myco chat interface.
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, None, 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
- raw = _run_pipeline(pipe, messages)
 
400
  print("========== MYCO OUTPUT ==========")
401
- print(raw)
402
  print("=================================")
403
- return _parse_llm_output(str(raw))
 
 
404
  except Exception as exc:
405
  print(f"[Myco] Inference error: {exc}")
406
  traceback.print_exc()
407
- return None, None, None
408
 
409
- # โ”€โ”€ Context builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
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
- # โ”€โ”€ Fallbacks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
445
- def _fallback_discover(current: dict) -> tuple[str, str, str]:
 
 
 
446
  name = current.get("name", "something")
447
  rarity = current.get("rarity", "Common")
448
- if current.get("name", "") in POISONOUS:
449
- return (
450
- f"Wait โ€” {name}! I've seen this before... something feels very wrong. Don't touch it yet.",
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
- f"A {name}... I can feel it humming. Something rare is here โ€” maybe magical.",
461
- "rare", "wonder",
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) -> tuple[str, str, str]:
468
  if _is_poisonous(current):
469
- return "๐Ÿ’€ That was poisonous! I tried to stop you... the forest goes dark.", "danger", "sad"
470
- score = RARITY_SCORE.get(current.get("rarity", "Common"), 10)
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 _fallback_collect(current: dict) -> tuple[str, str, str]:
478
- return f"Added {current.get('name','it')} to the MycoDex! The pages feel warmer.", "excited", "proud"
 
 
 
 
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 _fallback_chat(current: dict | None) -> tuple[str, str, str]:
 
 
 
 
487
  if current:
488
- return (
489
- f"I feel something strange about {current.get('name','this')}... stay close to me.",
490
- "normal", "nervous",
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
- # โ”€โ”€ Mushroom helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
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": event["title"],
572
- "event_emoji": event["emoji"],
573
- "myco_mood": event["mood"],
574
- "reward_text": "Discover, then pick or collect.",
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
- # โ”€โ”€ Public game actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
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, mood, emotion = _llm(prompt, ctx)
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, current
634
-
635
  ctx = _ctx(current, coll)
636
- reply, mood, emotion = _llm_with_history(hist, clean, ctx)
637
- if reply is None:
638
- reply, mood, emotion = _fallback_chat(current)
639
-
640
- # Push mood/emotion back onto current so renders.py updates immediately.
641
- if current is not None:
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, current, _append(hist, "assistant", "We need to find a mushroom first!")
 
659
  if collection_contains(coll, current["name"]):
660
- return coll, current, _append(hist, "assistant", f"{current['name']} is already in the MycoDex!")
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, mood, emotion = _llm(prompt, ctx)
681
- if reply is None:
682
- reply, mood, emotion = _fallback_collect(current)
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, mood, emotion = _llm(prompt, ctx)
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, mood, emotion = _llm(prompt, ctx)
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 coll, None, _append(hist, "assistant", "Whispers only gather around a found mushroom.")
762
-
 
763
  ctx = _ctx(current, coll)
764
- prompt = (
765
- f"The player decides to follow a faint whisper echoing from the {current.get('name', 'mushroom')}. "
766
- f"Active mystery thread: {ctx.get('mystery_title')}. Clue context: {ctx.get('mystery_clue')}. "
767
- "Describe Myco guiding them along a shifting, forgotten trail. Maintain a high degree of wonder "
768
- "or underlying anxiety depending on the danger status of the location."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  )
770
-
771
- reply, mood, emotion = _llm(prompt, ctx)
772
- if reply is None:
773
- reply, mood, emotion = _fallback_whisper(current)
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