byte-vortex commited on
Commit
b0fc007
·
verified ·
1 Parent(s): da159f0

Deploy Myco from CI

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