byte-vortex commited on
Commit
ca060df
·
verified ·
1 Parent(s): 0854770

Deploy Myco from CI

Browse files
Files changed (1) hide show
  1. game/engine.py +674 -375
game/engine.py CHANGED
@@ -1,399 +1,698 @@
1
- """Gradio game UI for Myco — LLM-powered forest adventure."""
 
 
2
 
3
- import ast
4
- import json
5
- from typing import Any, cast
6
- import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
- from game.engine import (
9
- collect_current, discover_mushroom, eat_current,
10
- follow_whisper, hf_companion_status, myco_reply,
11
- pick_current, study_current,
12
- )
13
- from game.state import welcome_history
14
- from models.mushroom import Mushroom
15
-
16
- from ui.renderers import (
17
- dex_markdown, forest_scene, game_intro_markdown,
18
- game_status_markdown, hero_markdown, home_forest_markdown,
19
- mushroom_card, play_hook_markdown, progress_markdown,
20
- world_map_markdown,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  )
22
 
23
- from ui.interface import render_shroom_tab
24
-
25
- EMPTY_COLLECTION = None
26
- START_POSITION = (1, 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- def force_parse_json_or_literal(s: str) -> Any:
29
- s = s.strip()
30
- if not (s.startswith("[") or s.startswith("{")):
31
- return s
32
  try:
33
- return json.loads(s)
34
- except Exception:
35
- pass
36
- try:
37
- return ast.literal_eval(s)
38
- except Exception:
39
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  try:
41
- s_json = s.replace("None", "null").replace("True", "true").replace("False", "false")
42
- return json.loads(s_json)
43
- except Exception:
44
- pass
45
- return s
46
-
47
- def extract_clean_text(val: Any) -> str:
48
- if val is None:
49
- return ""
50
- if isinstance(val, str):
51
- val_stripped = val.strip()
52
- if (val_stripped.startswith("[") and val_stripped.endswith("]")) or (val_stripped.startswith("{") and val_stripped.endswith("}")):
53
- parsed = force_parse_json_or_literal(val_stripped)
54
- if isinstance(parsed, str):
55
- return parsed
56
- return extract_clean_text(parsed)
57
- return val
58
- if isinstance(val, dict):
59
- if "text" in val:
60
- return extract_clean_text(val["text"])
61
- if "content" in val:
62
- return extract_clean_text(val["content"])
63
- return " ".join([extract_clean_text(v) for v in val.values() if v])
64
- if isinstance(val, list):
65
- segments = []
66
- for item in val:
67
- cleaned = extract_clean_text(item)
68
- if cleaned:
69
- segments.append(cleaned)
70
- return "\n".join(segments)
71
- return str(val)
72
-
73
- def _normalize_history(history):
74
- if not history:
75
- return []
76
- if isinstance(history, str):
77
- history = force_parse_json_or_literal(history)
78
- if not isinstance(history, (list, tuple)):
79
- history = [history]
80
- raw_normalized = []
81
- for item in history:
82
- if item is None:
83
- continue
84
- if isinstance(item, str):
85
- item = force_parse_json_or_literal(item)
86
- role = "assistant"
87
- content_val = item
88
- if isinstance(item, dict):
89
- role = item.get("role", "assistant")
90
- content_val = item.get("content", item.get("text", item))
91
- elif isinstance(item, (list, tuple)) and len(item) == 2:
92
- user_side, bot_side = item
93
- if user_side:
94
- raw_normalized.append({"role": "user", "content": extract_clean_text(user_side)})
95
- if bot_side:
96
- raw_normalized.append({"role": "assistant", "content": extract_clean_text(bot_side)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  continue
98
- clean_text = extract_clean_text(content_val)
99
- if clean_text:
100
- if isinstance(clean_text, str) and (clean_text.strip().startswith("[") or clean_text.strip().startswith("{")):
101
- nested_parsed = force_parse_json_or_literal(clean_text)
102
- if isinstance(nested_parsed, (list, dict)):
103
- raw_normalized.extend(_normalize_history(nested_parsed))
104
- continue
105
- raw_normalized.append({"role": role, "content": clean_text})
106
- reconciled = []
107
- for msg in raw_normalized:
108
- role = msg["role"]
109
- content = msg["content"]
110
- if reconciled and reconciled[-1]["role"] == role:
111
- reconciled[-1]["content"] += f"\n{content}"
112
- else:
113
- reconciled.append({"role": role, "content": content})
114
- return reconciled
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- def _denormalize_history(history):
117
- return _normalize_history(history)
118
 
119
- def mushroom_card_from_state(current):
 
 
 
 
 
 
 
120
  if current is None:
121
- return mushroom_card(None)
122
- if isinstance(current, str):
123
- current = force_parse_json_or_literal(current)
124
- if isinstance(current, str):
125
- return mushroom_card(None)
126
- return mushroom_card(
127
- Mushroom.from_dict(current),
128
- current.get("clue"),
129
- current.get("secret"),
130
- current,
 
 
 
 
 
 
 
 
 
 
 
 
131
  )
 
 
132
 
133
- def search_forest(position, collection):
134
- pos = _safe_pos(position)
135
- mushroom, current, history = discover_mushroom(collection)
136
- if isinstance(current, str):
137
- current = force_parse_json_or_literal(current)
138
- return (
139
- forest_scene(current, collection, pos),
140
- game_status_markdown(current, collection, pos),
141
- mushroom_card_from_state(current),
142
- current,
143
- _normalize_history(history),
144
- dex_markdown(collection),
145
- progress_markdown(collection),
146
- )
147
 
148
- def ask_companion(message, history, current, collection, position):
149
- legacy_hist = _denormalize_history(history)
150
- if legacy_hist and legacy_hist[-1]["role"] == "user":
151
- legacy_hist.append({"role": "assistant", "content": "..."})
152
- result = myco_reply(message, legacy_hist, current, collection, position)
153
- if isinstance(result, (tuple, list)) and len(result) == 2:
154
- return result[0], _normalize_history(result[1])
155
- return "", _normalize_history(result)
156
-
157
- def collect_discovery(current, collection, history, position):
158
- legacy_hist = _denormalize_history(history)
159
- updated_coll, updated_hist = collect_current(current, collection, legacy_hist)
160
- pos = _safe_pos(position)
161
- if isinstance(current, str):
162
- current = force_parse_json_or_literal(current)
163
- return (
164
- updated_coll,
165
- forest_scene(current, updated_coll, pos),
166
- game_status_markdown(current, updated_coll, pos),
167
- dex_markdown(updated_coll),
168
- world_map_markdown(updated_coll),
169
- _normalize_history(updated_hist),
170
- progress_markdown(updated_coll),
171
- )
172
 
173
- def pick_discovery(current, collection, history, position):
174
- legacy_hist = _denormalize_history(history)
175
- updated_coll, updated_current, updated_hist = pick_current(current, collection, legacy_hist)
176
- if isinstance(updated_current, str):
177
- updated_current = force_parse_json_or_literal(updated_current)
178
- pos = _safe_pos(position)
179
- return (
180
- updated_coll,
181
- updated_current,
182
- forest_scene(updated_current, updated_coll, pos),
183
- game_status_markdown(updated_current, updated_coll, pos),
184
- mushroom_card_from_state(updated_current),
185
- dex_markdown(updated_coll),
186
- world_map_markdown(updated_coll),
187
- _normalize_history(updated_hist),
188
- progress_markdown(updated_coll),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  )
 
 
 
 
 
 
 
 
190
 
191
- def follow_story_whisper(current, collection, history, position):
192
- legacy_hist = _denormalize_history(history)
193
- updated_current, updated_hist = follow_whisper(current, collection, legacy_hist)
194
- if isinstance(updated_current, str):
195
- updated_current = force_parse_json_or_literal(updated_current)
196
- pos = _safe_pos(position)
197
- return (
198
- updated_current,
199
- forest_scene(updated_current, collection, pos),
200
- game_status_markdown(updated_current, collection, pos),
201
- mushroom_card_from_state(updated_current),
202
- _normalize_history(updated_hist),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  )
 
 
 
 
204
 
205
- def study_discovery(current, history, collection, position):
206
- legacy_hist = _denormalize_history(history)
207
- updated_current, updated_hist = study_current(current, legacy_hist)
208
- if isinstance(updated_current, str):
209
- updated_current = force_parse_json_or_literal(updated_current)
210
- pos = _safe_pos(position)
211
- return (
212
- updated_current,
213
- forest_scene(updated_current, collection, pos),
214
- game_status_markdown(updated_current, collection, pos),
215
- mushroom_card_from_state(updated_current),
216
- _normalize_history(updated_hist),
217
  )
 
 
 
 
 
 
 
 
218
 
219
- def move_player(position, direction, current, collection):
220
- x, y = _safe_pos(position)
221
- if direction == "north": y -= 1
222
- elif direction == "south": y += 1
223
- elif direction == "west": x -= 1
224
- elif direction == "east": x += 1
225
- pos = _safe_pos((x, y))
226
- if isinstance(current, str):
227
- current = force_parse_json_or_literal(current)
228
- # Moving just updates position — no LLM call, no discovery.
229
- # The player clicks Search Clearing when they want to find a mushroom.
230
- return (
231
- pos,
232
- forest_scene(current, collection, pos),
233
- game_status_markdown(current, collection, pos),
234
- mushroom_card_from_state(current),
235
- current,
236
- gr.update(),
237
- dex_markdown(collection),
238
- progress_markdown(collection),
239
  )
 
 
 
240
 
241
- def block_unsafe_eating(current, history):
242
- legacy_hist = _denormalize_history(history)
243
- updated_hist = eat_current(current, legacy_hist)
244
- return _normalize_history(updated_hist)
245
-
246
- def open_play_tab():
247
- return gr.update(selected="play")
248
-
249
- def _safe_pos(position):
250
- if position is None:
251
- return START_POSITION
252
- x, y = position
253
- return (max(0, min(2, int(x))), max(0, min(2, int(y))))
254
-
255
- GUIDE_MARKDOWN = """
256
- ## How to play
257
-
258
- 1. Go to **🎮 Play** and click **🎮 Search Clearing** to discover a mushroom.
259
- 2. **Myco (the AI) reacts** — read what it senses about the mushroom.
260
- 3. Choose your action:
261
- - **🔍 Study** — Myco examines the mushroom and updates its magic field.
262
- - **🍄 Pick** — grab it as a game item. Poisonous picks = Game Over!
263
- - **📖 Collect** — add it to the MycoDex for safe keeping and score.
264
- - **🌌 Follow Whisper** — reveal mystery fragments. Risky on unstudied mushrooms!
265
- - **🍽️ Eat?** — Myco will stop you every time (for good reason).
266
- 4. **Ask Myco** anything in the chat — it knows the forest, the mystery, and the mushrooms.
267
- 5. Use **⬆️⬇️⬅️➡️** to move through the 3×3 forest grid.
268
-
269
- **Scoring:** Common = 10 spores · Rare = 35 · Legendary = 100 · Poison = −25 + Game Over
270
-
271
- **Poisonous mushrooms:** Ghost Gill · Pepper Pixie · Ruby Knuckle · Clockwork Chanterelle
272
- """
273
 
274
- def build_app():
275
- with gr.Blocks(title="Myco — Forest Adventure") as demo:
276
- gr.HTML("""
277
- <style>
278
- .gradio-container { background: #1a1a2e !important; }
279
- #forest-scene, #discovery-card { contain: layout style; content-visibility: auto; }
280
- .progress-level, .progress-level-inner, .generating { display: none !important; }
281
- </style>
282
- """)
283
-
284
- current_mushroom = gr.State(None)
285
- collection = gr.State([])
286
- player_position = gr.State(START_POSITION)
287
-
288
- gr.HTML(hero_markdown())
289
-
290
- with gr.Tabs(selected="home") as tabs:
291
- with gr.Tab("🏠 Home", id="home"):
292
- gr.HTML(home_forest_markdown())
293
- start_play_button = gr.Button(
294
- "🌲 Enter the Forest",
295
- variant="primary",
296
- size="lg",
297
- elem_classes=["forest-start-btn"]
298
- )
299
-
300
- with gr.Tab("🎮 Play", id="play"):
301
- gr.HTML(game_intro_markdown())
302
- scene = gr.HTML(forest_scene(None, EMPTY_COLLECTION, START_POSITION), elem_id="forest-scene")
303
-
304
- with gr.Row():
305
- with gr.Column(scale=1):
306
- search_button = gr.Button("🎮 Search Clearing", variant="primary", size="lg")
307
- with gr.Column(scale=1):
308
- gr.Markdown("**Move Myco**")
309
- with gr.Row():
310
- gr.HTML("<div></div>")
311
- north_button = gr.Button("⬆️", size="sm")
312
- gr.HTML("<div></div>")
313
- with gr.Row():
314
- west_button = gr.Button("⬅️", size="sm")
315
- pick_button = gr.Button("🍄 Pick", variant="primary", size="sm")
316
- east_button = gr.Button("➡️", size="sm")
317
- with gr.Row():
318
- gr.HTML("<div></div>")
319
- south_button = gr.Button("⬇️", size="sm")
320
- gr.HTML("<div></div>")
321
-
322
- status = gr.Markdown(game_status_markdown(None, EMPTY_COLLECTION, START_POSITION))
323
-
324
- with gr.Row(equal_height=True, elem_id="discovery-card"):
325
- with gr.Column(scale=2):
326
- gr.Markdown("### 🍄 Current Discovery")
327
- discovery = gr.Markdown(mushroom_card(None))
328
- with gr.Row():
329
- study_button = gr.Button("🔍 Study", variant="secondary")
330
- collect_button = gr.Button("🧺 Collect", variant="secondary")
331
- with gr.Row():
332
- whisper_button = gr.Button("🌌 Follow Whisper", variant="secondary")
333
- eat_button = gr.Button("🍽️ Eat?", variant="stop")
334
-
335
- with gr.Column(scale=3):
336
- gr.Markdown("### 💬 Chat with Myco")
337
- chat = gr.Chatbot(value=cast(Any, _normalize_history(welcome_history())), label="Myco", height=400, show_label=False)
338
- with gr.Row():
339
- prompt = gr.Textbox(placeholder="Ask Myco about this place...", show_label=False, scale=4, lines=1)
340
- ask_button = gr.Button("Ask 🍄", variant="primary", scale=1)
341
-
342
- with gr.Tab("📖 MycoDex"):
343
- with gr.Row():
344
- progress = gr.Markdown(progress_markdown(EMPTY_COLLECTION))
345
- dex = gr.Markdown(dex_markdown(EMPTY_COLLECTION))
346
- with gr.Group(elem_classes="myco-image-2"):
347
- gr.Image(value="assets/images/Myco-2.png", show_label=False, container=False, interactive=False)
348
-
349
- with gr.Tab("🗺️ Map"):
350
- world_map = gr.HTML(world_map_markdown(EMPTY_COLLECTION))
351
-
352
- with gr.Tab("🧭 Guide"):
353
- gr.Markdown(GUIDE_MARKDOWN)
354
- with gr.Group(elem_classes="myco-image"):
355
- gr.Image(value="assets/images/Myco.png", show_label=False, container=False, interactive=False)
356
-
357
- with gr.Tab("🍄 Myco"):
358
- render_shroom_tab()
359
-
360
- # ── Event wiring ──
361
- start_play_button.click(open_play_tab, outputs=[tabs])
362
-
363
- search_button.click(search_forest, inputs=[player_position, collection],
364
- outputs=[scene, status, discovery, current_mushroom, chat, dex, progress], show_progress="hidden")
365
-
366
- ask_button.click(ask_companion, inputs=[prompt, chat, current_mushroom, collection, player_position],
367
- outputs=[prompt, chat], show_progress="hidden")
368
- prompt.submit(ask_companion, inputs=[prompt, chat, current_mushroom, collection, player_position],
369
- outputs=[prompt, chat], show_progress="hidden")
370
-
371
- # FIX: each button gets its own .click() with a hardcoded gr.State string
372
- # so the direction value is baked in at definition time, not captured from
373
- # a loop variable (which would make all four buttons pass "east").
374
- north_button.click(move_player, inputs=[player_position, gr.State("north"), current_mushroom, collection],
375
- outputs=[player_position, scene, status, discovery, current_mushroom, chat, dex, progress], show_progress="hidden")
376
- south_button.click(move_player, inputs=[player_position, gr.State("south"), current_mushroom, collection],
377
- outputs=[player_position, scene, status, discovery, current_mushroom, chat, dex, progress], show_progress="hidden")
378
- west_button.click(move_player, inputs=[player_position, gr.State("west"), current_mushroom, collection],
379
- outputs=[player_position, scene, status, discovery, current_mushroom, chat, dex, progress], show_progress="hidden")
380
- east_button.click(move_player, inputs=[player_position, gr.State("east"), current_mushroom, collection],
381
- outputs=[player_position, scene, status, discovery, current_mushroom, chat, dex, progress], show_progress="hidden")
382
-
383
- study_button.click(study_discovery, inputs=[current_mushroom, chat, collection, player_position],
384
- outputs=[current_mushroom, scene, status, discovery, chat], show_progress="hidden")
385
-
386
- collect_button.click(collect_discovery, inputs=[current_mushroom, collection, chat, player_position],
387
- outputs=[collection, scene, status, dex, world_map, chat, progress], show_progress="hidden")
388
-
389
- pick_button.click(pick_discovery, inputs=[current_mushroom, collection, chat, player_position],
390
- outputs=[collection, current_mushroom, scene, status, discovery, dex, world_map, chat, progress], show_progress="hidden")
391
-
392
- whisper_button.click(follow_story_whisper, inputs=[current_mushroom, collection, chat, player_position],
393
- outputs=[current_mushroom, scene, status, discovery, chat], show_progress="hidden")
394
-
395
- eat_button.click(block_unsafe_eating, inputs=[current_mushroom, chat], outputs=[chat], show_progress="hidden")
396
-
397
- demo.queue()
398
- return demo
399
-
 
1
+ """
2
+ Core Myco gameplay — LLM is the primary game engine.
3
+ """
4
 
5
+ import os
6
+ import random
7
+ import threading
8
+ import torch
9
+ from transformers import AutoModelForCausalLM, AutoTokenizer
10
+
11
+ # 1. REMOVED: Circular import "from game.engine import ..."
12
+ # 2. Add local imports for dependencies
13
+ from game.catalog import load_mushrooms
14
+ from game.state import collection_contains, welcome_history
15
+
16
+ # Global singletons
17
+ DEFAULT_MODEL_ID = "google/gemma-3-1b-it"
18
+ _model = None
19
+ _tokenizer = None
20
+ _lock = threading.Lock()
21
+
22
+ # Detect device dynamically (Use CPU by default for reliability)
23
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
24
+
25
+ def _get_model_and_tokenizer():
26
+ global _model, _tokenizer
27
+ if _model is not None:
28
+ return _model, _tokenizer
29
+
30
+ with _lock:
31
+ if _model is not None:
32
+ return _model, _tokenizer
33
+
34
+ model_id = os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
35
+ token = os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN")
36
+
37
+ try:
38
+ print(f"[Myco] Loading model on: {DEVICE.upper()}...")
39
+ _tokenizer = AutoTokenizer.from_pretrained(model_id, token=token)
40
+
41
+ # Load model explicitly
42
+ _model = AutoModelForCausalLM.from_pretrained(
43
+ model_id,
44
+ token=token,
45
+ torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
46
+ device_map=DEVICE,
47
+ trust_remote_code=True
48
+ )
49
+ return _model, _tokenizer
50
+ except Exception as exc:
51
+ print(f"[Myco] Load error: {exc}")
52
+ return None, None
53
+
54
+ def _run_pipeline(messages):
55
+ """Run inference without @spaces.GPU decorator."""
56
+ model, tokenizer = _get_model_and_tokenizer()
57
+ if model is None:
58
+ return "The forest is silent. (Model failed to load)"
59
+
60
+ formatted_prompt = tokenizer.apply_chat_template(
61
+ messages, tokenize=False, add_generation_prompt=True
62
+ )
63
 
64
+ # Ensure inputs are on the correct device
65
+ inputs = tokenizer(formatted_prompt, return_tensors="pt").to(DEVICE)
66
+
67
+ with torch.no_grad():
68
+ outputs = model.generate(
69
+ **inputs,
70
+ max_new_tokens=256,
71
+ do_sample=True,
72
+ temperature=0.7,
73
+ pad_token_id=tokenizer.eos_token_id
74
+ )
75
+
76
+ input_length = inputs.input_ids.shape[1]
77
+ generated_tokens = outputs[0][input_length:]
78
+ return tokenizer.decode(generated_tokens, skip_special_tokens=True)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Template Helper (Fixes the Gemma TemplateError)
83
+ # ---------------------------------------------------------------------------
84
+ def _format_messages_for_template(system_prompt: str, user_prompt: str, history_turns: list = None) -> list:
85
+ """
86
+ Formats messages securely for models with strict chat templates (like Gemma).
87
+ - Starts the conversation with a 'user' turn containing the system prompt.
88
+ - Maps 'assistant' role tags directly to 'model'.
89
+ - Implements alternative processing to merge consecutive same-role text blocks.
90
+ """
91
+ # Gemma doesn't support the 'system' role, so we inject it as the first 'user' prompt
92
+ raw_turns = [{"role": "user", "content": system_prompt}]
93
+
94
+ if history_turns:
95
+ for entry in history_turns:
96
+ role = entry.get("role", "assistant")
97
+ if role == "assistant":
98
+ role = "model"
99
+ elif role == "system":
100
+ continue # Strip extraneous system tags
101
+
102
+ content = entry.get("content", "")
103
+ if isinstance(content, str) and content.strip():
104
+ raw_turns.append({"role": role, "content": content})
105
+
106
+ # Add the current payload action
107
+ raw_turns.append({"role": "user", "content": user_prompt})
108
+
109
+ # Deduplicate consecutive roles (guarantees perfect strict user/model rotation)
110
+ alternated_turns = []
111
+ for turn in raw_turns:
112
+ if alternated_turns and alternated_turns[-1]["role"] == turn["role"]:
113
+ alternated_turns[-1]["content"] += f"\n\n{turn['content']}"
114
+ else:
115
+ alternated_turns.append(turn)
116
+
117
+ return alternated_turns
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Game Constants
121
+ # ---------------------------------------------------------------------------
122
+ JSON_PROMPT_SUFFIX = (
123
+ "\n\nRespond with a single JSON object only. No prose before or after it. "
124
+ 'Example: {"action":"pick","target":"Ruby Knuckle","thought":"It seems safe."}'
125
  )
126
 
127
+ RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
128
+ RARITY_SCORE = {"Common": 10, "Rare": 35, "Legendary": 100}
129
+ PLAYER_HEALTH = 3
130
+ POISON_PENALTY = -25
131
+ POISONOUS = {"Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle"}
132
+
133
+ SYSTEM_PROMPT = """You are Myco, a tiny sentient mushroom companion and forest guide.
134
+ You are curious, warm, slightly anxious about poisonous mushrooms, and deeply connected
135
+ to the forest mystery. You speak in short, vivid sentences. You never break character.
136
+ You react emotionally to discoveries — with awe for Legendary mushrooms, caution for
137
+ poisonous ones, and gentle wonder for Common ones. You hint at the deeper mystery of
138
+ the vanished forest and the MycoDex that seems to remember things it shouldn't."""
139
+
140
+ FOREST_EVENTS = [
141
+ {"title": "A Quiet Clearing", "emoji": "🌿", "mood": "calm"},
142
+ {"title": "Wind Between Trees", "emoji": "🕯️", "mood": "afraid"},
143
+ ]
144
+
145
+ MYSTERY_CHAPTERS = [
146
+ {"threshold": 0, "title": "The Wrong Memory", "clue": "Myco recognises the path before you move."},
147
+ {"threshold": 1, "title": "The Traveler's Song", "clue": "A stranger's lullaby appears in the MycoDex margin."},
148
+ {"threshold": 2, "title": "The Door Under Roots","clue": "Rare spores point to a door nobody built."},
149
+ {"threshold": 3, "title": "The MycoDex Seed", "clue": "The MycoDex grows warm like a living cap."},
150
+ {"threshold": 5, "title": "The Impossible Bloom","clue": "The Impossible Mushroom was never outside the book."},
151
+ ]
152
+
153
+ RARITY_CLUES = {
154
+ "Common": "The cap leans toward the path, like it wants to be remembered.",
155
+ "Rare": "Silver spores circle it in a pattern only old forest stories describe.",
156
+ "Legendary": "The whole clearing goes quiet — this mushroom hides part of the Elder Map.",
157
+ }
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Utility: extract JSON or return text
162
+ # ---------------------------------------------------------------------------
163
+ def _extract_json_or_text(generated_text: str) -> str | None:
164
+ if not generated_text:
165
+ return None
166
+ text = str(generated_text).strip()
167
+
168
+ simple_matches = re.findall(r"\{.*?\}|\[.*?\]", text, flags=re.DOTALL)
169
+ if simple_matches:
170
+ for candidate in reversed(simple_matches):
171
+ try:
172
+ parsed = json.loads(candidate)
173
+ return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
174
+ except Exception:
175
+ continue
176
+
177
+ return text or None
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Status
182
+ # ---------------------------------------------------------------------------
183
+ def companion_model_id():
184
+ return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
185
+
186
+
187
+ def companion_status():
188
+ pipe = _get_pipeline()
189
+ model = companion_model_id()
190
+ if pipe:
191
+ return f"🧠 Myco AI active ({model})"
192
+ return f"⚠️ Myco AI fallback mode ({model} failed)"
193
+
194
+
195
+ def hf_companion_status():
196
+ return companion_status()
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # LLM call — single-turn
201
+ # ---------------------------------------------------------------------------
202
+ def _llm(prompt: str, context: dict | None = None) -> str | None:
203
+ pipe = _get_pipeline()
204
+ if not pipe:
205
+ return None
206
+
207
+ ctx = context or {}
208
+ mushroom_line = ""
209
+ if ctx.get("name"):
210
+ poison_flag = " ⚠️ POISONOUS" if ctx.get("name") in POISONOUS else ""
211
+ mushroom_line = (
212
+ f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')} rarity){poison_flag}. "
213
+ f"Habitat: {ctx.get('habitat','?')}. Lore: {ctx.get('lore','?')}. "
214
+ f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}. "
215
+ f"Danger: {ctx.get('danger','Unknown')}."
216
+ )
217
+
218
+ collection_line = f"MycoDex entries: {ctx.get('collection_count', 0)}."
219
+ mystery_line = f"Active mystery chapter: {ctx.get('mystery_title', 'The Wrong Memory')}."
220
+ score_line = f"Player score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
221
+
222
+ system = f"{SYSTEM_PROMPT}\n\n{mushroom_line}\n{collection_line}\n{mystery_line}\n{score_line}"
223
+
224
+ # Safe layout handling for strict model templates
225
+ messages = _format_messages_for_template(system, prompt + JSON_PROMPT_SUFFIX)
226
 
 
 
 
 
227
  try:
228
+ outputs = _run_pipeline(pipe, messages)
229
+ print("========== MYCO OUTPUT ==========")
230
+ print(outputs)
231
+ print("=================================")
232
+
233
+ if isinstance(outputs, list) and outputs:
234
+ first = outputs[0]
235
+ generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
236
+ else:
237
+ generated = str(outputs)
238
+
239
+ if isinstance(generated, list):
240
+ last = generated[-1]
241
+ text = last.get("content") if isinstance(last, dict) else str(last)
242
+ else:
243
+ text = str(generated)
244
+
245
+ return _extract_json_or_text(text)
246
+ except Exception as exc:
247
+ print(f"[Myco] Inference error: {exc}")
248
+ traceback.print_exc()
249
+ return None
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # LLM call — multi-turn chat
254
+ # ---------------------------------------------------------------------------
255
+ def _llm_with_history(history: list, user_message: str, context: dict) -> str | None:
256
+ pipe = _get_pipeline()
257
+ if not pipe:
258
+ return None
259
+
260
+ ctx = context or {}
261
+ mushroom_line = ""
262
+ if ctx.get("name"):
263
+ poison_flag = " ⚠️ POISONOUS" if ctx.get("name") in POISONOUS else ""
264
+ mushroom_line = (
265
+ f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')}){poison_flag}. "
266
+ f"Lore: {ctx.get('lore','?')}. "
267
+ f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}."
268
+ )
269
+
270
+ system = (
271
+ f"{SYSTEM_PROMPT}\n\n"
272
+ f"{mushroom_line}\n"
273
+ f"MycoDex entries: {ctx.get('collection_count', 0)}. "
274
+ f"Mystery: {ctx.get('mystery_title', 'The Wrong Memory')}. "
275
+ f"Score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
276
+ )
277
+
278
+ # Safe structure formatting running history securely through template checks
279
+ messages = _format_messages_for_template(system, user_message, history[-6:])
280
+
281
  try:
282
+ reply = _run_pipeline(pipe, messages)
283
+
284
+ print("========== MYCO OUTPUT ==========")
285
+ print(reply)
286
+ print("=================================")
287
+
288
+ return reply
289
+
290
+ except Exception as exc:
291
+ print(f"[Myco] Inference error: {exc}")
292
+ traceback.print_exc()
293
+ return None
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Context builder
298
+ # ---------------------------------------------------------------------------
299
+ def _ctx(current: dict | None, collection: list) -> dict:
300
+ count = len(collection)
301
+ chapter = MYSTERY_CHAPTERS[0]
302
+ for c in MYSTERY_CHAPTERS:
303
+ if count >= c["threshold"]:
304
+ chapter = c
305
+ score = _score_collection(collection)
306
+ health = _health(current, collection)
307
+ ctx: dict = {
308
+ "collection_count": count,
309
+ "mystery_title": chapter["title"],
310
+ "mystery_clue": chapter["clue"],
311
+ "score": score,
312
+ "health": health,
313
+ }
314
+ if current:
315
+ ctx.update({
316
+ "name": current.get("name", ""),
317
+ "rarity": current.get("rarity", "Common"),
318
+ "habitat": current.get("habitat", ""),
319
+ "lore": current.get("lore", ""),
320
+ "edible": current.get("edible", "Unknown"),
321
+ "magic": current.get("magic", "Unknown"),
322
+ "danger": current.get("danger", "Unknown"),
323
+ })
324
+ return ctx
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Fallbacks
329
+ # ---------------------------------------------------------------------------
330
+ def _fallback_discover(current: dict) -> str:
331
+ name = current.get("name", "something")
332
+ rarity = current.get("rarity", "Common")
333
+ poison = current.get("name", "") in POISONOUS
334
+ if poison:
335
+ return f"Wait {name}! I've seen this before... something feels very wrong. Don't touch it yet."
336
+ if rarity == "Legendary":
337
+ return f"Oh! Oh! A {name}! The whole clearing just went silent. This is from the Elder Map!"
338
+ if rarity == "Rare":
339
+ return f"A {name}... I can feel it humming. Something rare is here — maybe magical."
340
+ return f"A {name}! Found near {current.get('habitat','the forest')}. Let me sense it first."
341
+
342
+
343
+ def _fallback_pick(current: dict) -> str:
344
+ if _is_poisonous(current):
345
+ return "💀 That was poisonous! I tried to stop you... the forest goes dark."
346
+ return f"Got {current.get('name','it')}! +{RARITY_SCORE.get(current.get('rarity','Common'),10)} spores!"
347
+
348
+
349
+ def _fallback_study(current: dict) -> str:
350
+ return f"I studied it carefully. Magic field updated. The clue: {RARITY_CLUES.get(current.get('rarity','Common'), '')}"
351
+
352
+
353
+ def _fallback_collect(current: dict) -> str:
354
+ return f"Added {current.get('name','it')} to the MycoDex! The pages feel warmer."
355
+
356
+
357
+ def _fallback_whisper(current: dict) -> str:
358
+ return "I followed the whisper... and remembered a path I've never walked. The mystery deepens."
359
+
360
+
361
+ def _fallback_chat(current: dict | None) -> str:
362
+ if current:
363
+ return f"I feel something strange about {current.get('name','this')}... stay close to me."
364
+ return "The forest is full of secrets. Move to a clearing and search — I'll watch for danger."
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Mushroom helpers
369
+ # ---------------------------------------------------------------------------
370
+ def _choose_mushroom(catalog=None):
371
+ mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
372
+ weights = [RARITY_WEIGHTS.get(m.rarity, 12) for m in mushrooms]
373
+ return random.choices(mushrooms, weights=weights, k=1)[0]
374
+
375
+
376
+ def _is_poisonous(current: dict) -> bool:
377
+ return current.get("name", "") in POISONOUS or current.get("danger") == "Poisonous"
378
+
379
+
380
+ def _score_value(current: dict) -> int:
381
+ return RARITY_SCORE.get(current.get("rarity", "Common"), 10)
382
+
383
+
384
+ def _score_collection(collection: list) -> int:
385
+ total = 0
386
+ for e in collection:
387
+ if e.get("game_over") == "Yes":
388
  continue
389
+ total += int(e.get("score_delta") or _score_value(e))
390
+ return max(0, total)
391
+
392
+
393
+ def _health(current: dict | None, collection: list) -> int:
394
+ if current and current.get("game_over") == "Yes":
395
+ return 0
396
+ deaths = sum(1 for e in collection if e.get("game_over") == "Yes")
397
+ return max(0, PLAYER_HEALTH - deaths)
398
+
399
+
400
+ def _mystery_state(count: int) -> dict:
401
+ chapter, next_ch = MYSTERY_CHAPTERS[0], None
402
+ for c in MYSTERY_CHAPTERS:
403
+ if count >= c["threshold"]:
404
+ chapter = c
405
+ elif next_ch is None:
406
+ next_ch = c
407
+ next_line = (
408
+ f"{next_ch['threshold'] - count} discoveries until {next_ch['title']}."
409
+ if next_ch else "The Impossible Bloom is near. Follow the whisper."
410
+ )
411
+ return {
412
+ "mystery_title": chapter["title"],
413
+ "mystery_clue": chapter["clue"],
414
+ "mystery_next": next_line,
415
+ }
416
+
417
+
418
+ def _story_event(count: int) -> dict:
419
+ return FOREST_EVENTS[count % len(FOREST_EVENTS)]
420
+
421
+
422
+ def _build_current(mushroom, collection: list) -> dict:
423
+ count = len(collection)
424
+ current = mushroom.to_dict()
425
+ current["poison"] = "Yes" if mushroom.name in POISONOUS else "No"
426
+ current["score_total"] = str(_score_collection(collection))
427
+ current["health"] = str(_health(current, collection))
428
+ current["score_delta"] = "0"
429
+ current["clue"] = RARITY_CLUES.get(mushroom.rarity, RARITY_CLUES["Common"])
430
+ if count == 0:
431
+ current["clue"] = f"First clue: {mushroom.name} marks the beginning of the Spore Door trail."
432
+ event = _story_event(count)
433
+ current.update({
434
+ "event_title": event["title"],
435
+ "event_emoji": event["emoji"],
436
+ "myco_mood": event["mood"],
437
+ "reward_text": "Discover, then pick or collect.",
438
+ })
439
+ current.update(_mystery_state(count))
440
+ return current
441
+
442
+
443
+ def _append(history: list, role: str, content: str) -> list:
444
+ return [*history, {"role": role, "content": content}]
445
+
446
+
447
+ def _safe_history(h) -> list:
448
+ return list(h or welcome_history())
449
+
450
+
451
+ def _safe_collection(c) -> list:
452
+ return list(c or [])
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Public game actions
457
+ # ---------------------------------------------------------------------------
458
+ def discover_mushroom(collection=None, catalog=None):
459
+ """Discover a new mushroom. LLM narrates the moment."""
460
+ coll = _safe_collection(collection)
461
+ mushroom = _choose_mushroom(catalog)
462
+ current = _build_current(mushroom, coll)
463
+ ctx = _ctx(current, coll)
464
+
465
+ prompt = (
466
+ f"The player just discovered a {mushroom.rarity} mushroom called {mushroom.name} "
467
+ f"near {mushroom.habitat}. "
468
+ f"{'WARNING: this mushroom is POISONOUS! React with fear and urgency.' if mushroom.name in POISONOUS else ''}"
469
+ f"{'This is LEGENDARY — react with awe and excitement!' if mushroom.rarity == 'Legendary' else ''}"
470
+ f"{'This is RARE — react with wonder and curiosity.' if mushroom.rarity == 'Rare' else ''}"
471
+ f"Forest event: {current['event_title']}. Myco mood: {current['myco_mood']}. "
472
+ f"Mystery chapter: {current['mystery_title']}. "
473
+ "React in character. Hint at what to do next (Study, Pick, Follow Whisper, or Collect)."
474
+ )
475
+ reply = _llm(prompt, ctx) or _fallback_discover(current)
476
+ history = _append(welcome_history(), "assistant", reply)
477
+ return mushroom, current, history
478
+
479
+
480
+ def myco_reply(message=None, history=None, current=None, collection=None, position=None):
481
+ """Player chats with Myco. LLM responds in character with full context."""
482
+ print("MYCO_REPLY CALLED, message:", repr(message))
483
+ hist = _safe_history(history)
484
+ coll = _safe_collection(collection)
485
+ clean = (message or "").strip()
486
+ if not clean:
487
+ return "", hist
488
+
489
+ ctx = _ctx(current, coll)
490
+ reply = _llm_with_history(hist, clean, ctx)
491
+ if not reply:
492
+ reply = _fallback_chat(current)
493
+
494
+ return "", _append(hist, "user", clean) + [{"role": "assistant", "content": reply}]
495
 
 
 
496
 
497
+ companion_reply = myco_reply
498
+
499
+
500
+ def collect_current(current=None, collection=None, history=None):
501
+ """Collect mushroom into MycoDex. LLM narrates the entry."""
502
+ coll = _safe_collection(collection)
503
+ hist = _safe_history(history)
504
+
505
  if current is None:
506
+ return coll, _append(hist, "assistant", "We need to find a mushroom first!")
507
+
508
+ if collection_contains(coll, current["name"]):
509
+ return coll, _append(hist, "assistant", f"{current['name']} is already in the MycoDex!")
510
+
511
+ score_delta = _score_value(current)
512
+ score_total = _score_collection(coll) + score_delta
513
+ collected = {
514
+ **current,
515
+ "score_delta": str(score_delta),
516
+ "score_total": str(score_total),
517
+ "health": str(_health(current, coll)),
518
+ "reward_text": f"+{score_delta} spores",
519
+ }
520
+ updated_coll = [*coll, collected]
521
+ ctx = _ctx(current, coll)
522
+
523
+ prompt = (
524
+ f"The player just added {current['name']} ({current.get('rarity','Common')}) to the MycoDex! "
525
+ f"+{score_delta} spores. Total score: {score_total}. "
526
+ f"MycoDex now has {len(updated_coll)} entries. "
527
+ "Celebrate this moment. Add a small lore detail or mystery hint."
528
  )
529
+ reply = _llm(prompt, ctx) or _fallback_collect(current)
530
+ return updated_coll, _append(hist, "assistant", reply)
531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
+ def pick_current(current=None, collection=None, history=None):
534
+ """Pick mushroom as game item. Poison = game over. LLM narrates dramatically."""
535
+ coll = _safe_collection(collection)
536
+ hist = _safe_history(history)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
+ if current is None:
539
+ return coll, None, _append(hist, "assistant", "Find a mushroom first before picking!")
540
+
541
+ ctx = _ctx(current, coll)
542
+
543
+ if _is_poisonous(current):
544
+ score_total = max(0, _score_collection(coll) + POISON_PENALTY)
545
+ game_over = {
546
+ **current,
547
+ "danger": "Poisonous",
548
+ "game_over": "Yes",
549
+ "health": "0",
550
+ "score_delta": str(POISON_PENALTY),
551
+ "score_total": str(score_total),
552
+ "reward_text": f"Poison! {POISON_PENALTY} spores · Game Over",
553
+ }
554
+ prompt = (
555
+ f"DRAMATIC MOMENT: The player picked {current['name']} which is POISONOUS! "
556
+ f"Game Over! Score drops by 25 to {score_total}. Health → 0. "
557
+ "React with shock, sadness, and a dramatic farewell. Make it memorable."
558
+ )
559
+ reply = _llm(prompt, ctx) or _fallback_pick(current)
560
+ return coll, game_over, _append(hist, "assistant", f"💀 {reply}")
561
+
562
+ score_delta = _score_value(current)
563
+ score_total = _score_collection(coll) + score_delta
564
+ picked = {
565
+ **current,
566
+ "picked": "Yes",
567
+ "danger": "Safe",
568
+ "score_delta": str(score_delta),
569
+ "score_total": str(score_total),
570
+ "health": str(_health(current, coll)),
571
+ "reward_text": f"+{score_delta} spores",
572
+ }
573
+
574
+ if collection_contains(coll, picked["name"]):
575
+ return coll, picked, _append(hist, "assistant", f"{picked['name']} already picked!")
576
+
577
+ updated_coll = [*coll, picked]
578
+ prompt = (
579
+ f"The player safely picked {current['name']} ({current.get('rarity','Common')})! "
580
+ f"+{score_delta} spores. Total: {score_total}. "
581
+ "Celebrate! Make it feel like a platformer power-up moment."
582
  )
583
+ reply = _llm(prompt, ctx) or _fallback_pick(current)
584
+ return updated_coll, picked, _append(hist, "assistant", f"🍄 {reply}")
585
+
586
+
587
+ def follow_whisper(current=None, collection=None, history=None):
588
+ """Follow the forest whisper. LLM reveals mystery fragments."""
589
+ coll = _safe_collection(collection)
590
+ hist = _safe_history(history)
591
 
592
+ if current is None:
593
+ return None, _append(hist, "assistant",
594
+ "Myco cups one ear. The forest only whispers near mushrooms — search a clearing first.")
595
+
596
+ ctx = _ctx(current, coll)
597
+
598
+ if _is_poisonous(current) and current.get("studied") != "Yes":
599
+ game_over = {
600
+ **current,
601
+ "danger": "Poisonous",
602
+ "game_over": "Yes",
603
+ "health": "0",
604
+ "score_total": str(max(0, _score_collection(coll) + POISON_PENALTY)),
605
+ }
606
+ prompt = (
607
+ f"The player followed a whisper but it led to POISON from {current['name']}! Game Over! "
608
+ "React with horror and a haunting mystery revelation."
609
+ )
610
+ reply = _llm(prompt, ctx) or "💀 The whisper belonged to poison... Myco screams."
611
+ return game_over, _append(hist, "assistant", reply)
612
+
613
+ mystery = _mystery_state(len(coll) + 1)
614
+ prompt = (
615
+ f"The player followed a forest whisper near {current.get('name','a mushroom')}. "
616
+ f"Mystery chapter revealed: {mystery['mystery_title']}. Clue: {mystery['mystery_clue']}. "
617
+ "Reveal this mystery fragment dramatically. "
618
+ "Make Myco gasp or tremble. Hint that the MycoDex is alive and regrowing the lost forest."
619
  )
620
+ reply = _llm(prompt, ctx) or _fallback_whisper(current)
621
+ revealed = {**current, **mystery, "whisper_followed": "Yes"}
622
+ return revealed, _append(hist, "assistant", f"🌌 {reply}")
623
+
624
 
625
+ def study_current(current=None, history=None):
626
+ """Study mushroom. LLM gives a careful field observation."""
627
+ hist = _safe_history(history)
628
+
629
+ if current is None:
630
+ return None, _append(hist, "assistant", "Nothing to study yet — find a mushroom first!")
631
+
632
+ prompt = (
633
+ f"Study the mushroom {current.get('name','unknown')} carefully. "
634
+ "Provide a concise field observation and one hint about its magic or danger."
 
 
635
  )
636
+ reply = _llm(prompt, _ctx(current, [])) or _fallback_study(current)
637
+ return reply, _append(hist, "assistant", reply)
638
+
639
+
640
+ def eat_current(current=None, collection=None, history=None):
641
+ """Eating mushrooms raw is always blocked. This dynamic response is now fully driven by Myco AI."""
642
+ coll = _safe_collection(collection)
643
+ hist = _safe_history(history)
644
 
645
+ if current is None:
646
+ return coll, _append(hist, "assistant", "There's nothing here to eat!")
647
+
648
+ ctx = _ctx(current, coll)
649
+ prompt = (
650
+ f"The player is attempting to directly EAT the raw mushroom: {current.get('name','unknown')}. "
651
+ "This is highly forbidden, unsafe, and unidentified! React as Myco with absolute dynamic panic, "
652
+ "gently scold them in character for trying something so dangerous, and assertively tell them "
653
+ "they need to Study it instead of eating it!"
654
+ )
655
+
656
+ # Classic static text fallback just in case inference drops
657
+ fallback_reply = (
658
+ f"No no no! {current.get('name','That')} could be dangerous raw! "
659
+ "I'll never let you eat an unidentified mushroom. Study it first!"
 
 
 
 
 
660
  )
661
+
662
+ reply = _llm(prompt, ctx) or fallback_reply
663
+ return coll, _append(hist, "assistant", reply)
664
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
 
666
+ # Myco narrative
667
+
668
+ def get_myco_narrative(current=None):
669
+ # 1. Personality Prompt
670
+ prompt = (
671
+ "You are Myco, a tiny, emotional mushroom companion. "
672
+ "Write a whimsical, mysterious thought about the forest. "
673
+ "DO NOT use JSON. Do NOT use action tags. Just speak naturally."
674
+ )
675
+
676
+ context = {}
677
+ if current:
678
+ context = {"name": current.get("name"), "rarity": current.get("rarity")}
679
+
680
+ # 2. Get the response
681
+ reply = _llm(prompt, context=context)
682
+
683
+ # 3. CRITICAL: Aggressive Clean-up
684
+ # If the LLM ignored our "no JSON" instruction, we parse it ourselves.
685
+ if reply:
686
+ # If it's wrapped in JSON, extract the 'thought'
687
+ if reply.strip().startswith("{"):
688
+ try:
689
+ data = json.loads(reply)
690
+ return data.get("thought", "The forest feels deep today... ✨")
691
+ except:
692
+ pass # If it's broken JSON, just return the reply
693
+
694
+ # If it's just raw text, return it
695
+ return reply
696
+
697
+ return "The forest feels deep today... ✨"
698
+