byte-vortex commited on
Commit
70b785e
·
verified ·
1 Parent(s): 0158d0d

Deploy Myco from CI

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