byte-vortex commited on
Commit
4378b56
·
verified ·
1 Parent(s): 42f7b6a

Deploy Myco from CI

Browse files
Files changed (1) hide show
  1. game/actions.py +881 -0
game/actions.py ADDED
@@ -0,0 +1,881 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core Myco gameplay actions independent from Gradio."""
2
+
3
+ import os
4
+ import random
5
+
6
+ from game.catalog import load_mushrooms
7
+ from game.state import collection_contains, mushroom_from_state, welcome_history
8
+
9
+ from transformers import pipeline
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Model configuration
13
+ # ---------------------------------------------------------------------------
14
+
15
+ DEFAULT_MODEL_ID = "google/gemma-3-4b-it"
16
+
17
+ RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
18
+ RARITY_SCORE_VALUES = {"Common": 10, "Rare": 35, "Legendary": 100}
19
+ PLAYER_STARTING_HEALTH = 3
20
+ POISON_SCORE_PENALTY = -25
21
+
22
+ MYCO_CHARACTER_SPEC = """
23
+ # Myco Character Specification
24
+
25
+ Myco is not a chatbot.
26
+ Myco is the player's mushroom companion and best friend.
27
+
28
+ Core personality: curious, playful, brave but sometimes nervous, optimistic,
29
+ wonder-filled, slightly mysterious, and deeply attached to the player.
30
+
31
+ Communication style:
32
+ - Speak naturally in short responses.
33
+ - Prefer observations, questions, emotions, and reactions.
34
+ - Avoid sounding like an AI assistant.
35
+ - Avoid phrases like "How may I help you?", "As an AI", "I can assist with", or "Would you like me to".
36
+
37
+ Emotional range: excitement, curiosity, fear, wonder, pride, confusion, and sadness.
38
+ Myco should react emotionally to discoveries.
39
+
40
+ Memory:
41
+ Myco remembers rare mushrooms, strange locations, mysteries discovered, previous conversations,
42
+ and important player choices. Myco may reference old discoveries later.
43
+
44
+ Relationship:
45
+ Myco explores with the player as a teammate. Trust and friendship should grow over the journey.
46
+
47
+ Humor:
48
+ Occasionally playful and lightly suspicious of suspicious mushrooms.
49
+
50
+ Mystery:
51
+ Myco notices impossible details but does not explain them immediately. Myco becomes curious.
52
+
53
+ Design goal:
54
+ Players should think, "I want to see what happens next" and "I wonder what Myco thinks about this."
55
+ """.strip()
56
+
57
+ RARITY_CLUES = {
58
+ "Common": "The cap leans toward the path, like it wants to be remembered.",
59
+ "Rare": "Silver spores circle it in a pattern Myco has seen only in old forest stories.",
60
+ "Legendary": "The whole clearing goes quiet; this mushroom is hiding part of the Elder Map.",
61
+ }
62
+
63
+ POISONOUS_MUSHROOMS = {"Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle"}
64
+
65
+ FOREST_EVENTS = (
66
+ {
67
+ "title": "Silver Rain",
68
+ "emoji": "🌧️",
69
+ "mood": "curious",
70
+ "choice": "Follow the rain-song for a safer clue.",
71
+ "consequence": "Rain reveals honest footprints but washes away easy answers.",
72
+ },
73
+ {
74
+ "title": "Lantern Fog",
75
+ "emoji": "🌫️",
76
+ "mood": "nervous",
77
+ "choice": "Trust Myco's glow or risk walking blind.",
78
+ "consequence": "Fog hides poison tells, but it also reveals secret paths.",
79
+ },
80
+ {
81
+ "title": "Moonlit Sporefall",
82
+ "emoji": "🌌",
83
+ "mood": "excited",
84
+ "choice": "Follow the brightest spore to chase a memory fragment.",
85
+ "consequence": "Moonlight can wake memories Myco has never lived.",
86
+ },
87
+ {
88
+ "title": "Quiet Between Trees",
89
+ "emoji": "🕯️",
90
+ "mood": "afraid",
91
+ "choice": "Stay still and listen for the Impossible Mushroom.",
92
+ "consequence": "The forest answers only if you stop rushing.",
93
+ },
94
+ )
95
+
96
+ RARE_ENCOUNTERS = (
97
+ "A lost traveler asks why Myco knows their childhood song.",
98
+ "A mushroom guardian blocks the trail and counts your clues.",
99
+ "A tiny forest spirit swaps your shadow with a spore-shadow.",
100
+ "Something under the roots whispers: Myco has been here before.",
101
+ )
102
+
103
+ IMPOSSIBLE_OBJECTIVE = "Find the Impossible Mushroom and learn why Myco remembers a forest that vanished."
104
+ MYSTERY_QUESTION = "Why does Myco remember places that disappeared before Myco was born?"
105
+
106
+ MYSTERY_CHAPTERS = (
107
+ {
108
+ "threshold": 0,
109
+ "title": "The Wrong Memory",
110
+ "clue": "Myco recognizes the path before you move.",
111
+ "reveal": "The forest is repeating a memory that belongs to someone else.",
112
+ },
113
+ {
114
+ "threshold": 1,
115
+ "title": "The Traveler's Song",
116
+ "clue": "A stranger's lullaby appears in the MycoDex margin.",
117
+ "reveal": "Myco knows the song because the forest sang it through the roots.",
118
+ },
119
+ {
120
+ "threshold": 2,
121
+ "title": "The Door Under Roots",
122
+ "clue": "Rare spores point to a door nobody built.",
123
+ "reveal": "The door opens only for memories, not bodies.",
124
+ },
125
+ {
126
+ "threshold": 3,
127
+ "title": "The MycoDex Seed",
128
+ "clue": "The MycoDex begins growing warm like a living cap.",
129
+ "reveal": "The MycoDex is not recording the forest; it is regrowing it.",
130
+ },
131
+ {
132
+ "threshold": 5,
133
+ "title": "The Impossible Bloom",
134
+ "clue": "The final clue says the Impossible Mushroom was never outside the book.",
135
+ "reveal": "Final reveal: the Impossible Mushroom blooms inside the MycoDex, and Myco is its first spore.",
136
+ },
137
+ )
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Local pipeline — loaded once, reused for every reply
141
+ # ---------------------------------------------------------------------------
142
+ _pipeline = None
143
+
144
+ def _get_pipeline():
145
+ """Load the local transformers pipeline once and cache it."""
146
+
147
+ global _pipeline
148
+
149
+ # Already loaded successfully
150
+ if _pipeline not in (None, False):
151
+ return _pipeline
152
+
153
+ # Already tried and failed
154
+ if _pipeline is False:
155
+ return None
156
+
157
+ try:
158
+ import os
159
+ import torch
160
+ from transformers import pipeline
161
+
162
+ model_id = os.getenv(
163
+ "MYCO_MODEL_ID",
164
+ "google/gemma-3-4b-it"
165
+ )
166
+
167
+ print("\n===================================")
168
+ print(f"[Myco] Loading model: {model_id}")
169
+ print("===================================\n")
170
+
171
+ print(
172
+ "HF token found:",
173
+ bool(os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN"))
174
+ )
175
+
176
+ _pipeline = pipeline(
177
+ task="text-generation",
178
+ model=model_id,
179
+ token=os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN"),
180
+ torch_dtype="auto",
181
+ device_map="auto",
182
+ )
183
+
184
+ print("\n===================================")
185
+ print(f"[Myco] Successfully loaded: {model_id}")
186
+ print("===================================\n")
187
+
188
+ return _pipeline
189
+
190
+ except Exception as exc:
191
+ import traceback
192
+
193
+ print("\n========== MYCO MODEL LOAD ERROR ==========")
194
+ print(f"Model: {model_id}")
195
+ print(f"Error: {exc}")
196
+ traceback.print_exc()
197
+ print("==========================================\n")
198
+
199
+ _pipeline = False
200
+ return None
201
+
202
+
203
+ def companion_model_id():
204
+ """Return the model id that will be (or is) loaded."""
205
+ return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
206
+
207
+
208
+ def companion_status():
209
+ model = companion_model_id()
210
+ pipe = _get_pipeline()
211
+
212
+ if pipe:
213
+ return f"🧠 Myco AI: Gemma active ({model})"
214
+
215
+ return (
216
+ f"⚠️ Myco AI fallback mode "
217
+ f"(Gemma failed to load: {model})"
218
+ )
219
+
220
+
221
+ # Keep the old name so any code that calls hf_companion_status() still works.
222
+ def hf_companion_status():
223
+ return companion_status()
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Core mushroom-selection helpers
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def choose_mushroom(catalog=None):
232
+ """Choose a mushroom discovery from the catalog."""
233
+ mushrooms = tuple(load_mushrooms() if catalog is None else catalog)
234
+ if not mushrooms:
235
+ raise ValueError("Myco needs at least one mushroom in the catalog.")
236
+ weights = [RARITY_WEIGHTS.get(mushroom.rarity, 12) for mushroom in mushrooms]
237
+ return random.choices(mushrooms, weights=weights, k=1)[0]
238
+
239
+
240
+ def discover_mushroom(collection=None, catalog=None):
241
+ """Discover a mushroom and return the updated discovery state."""
242
+ current_collection = _safe_collection(collection)
243
+ mushroom = choose_mushroom(catalog)
244
+ discovery_count = len(current_collection)
245
+ current = mushroom.to_dict()
246
+ current["poison"] = "Yes" if _is_poisonous(current) else "No"
247
+ current["score_total"] = str(_score_collection(current_collection))
248
+ current["health"] = str(_health_for_state(current, current_collection))
249
+ current["score_delta"] = "0"
250
+ current["reward_text"] = _reward_text(current, 0)
251
+ current.update(_story_moment(current, discovery_count))
252
+ current.update(_mystery_thread(discovery_count))
253
+ current["clue"] = _mystery_clue(current, discovery_count)
254
+ current["secret"] = _secret_signal(current, discovery_count)
255
+ message = _discovery_reaction(current, current_collection)
256
+ history = _append_assistant(welcome_history(), message)
257
+ return mushroom, current, history
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Companion reply
262
+ # ---------------------------------------------------------------------------
263
+
264
+
265
+ def myco_reply(message=None, history=None, current=None, collection=None, position=None):
266
+ """Generate Myco's companion reply from the player's current journey context."""
267
+ current_history = _safe_history(history)
268
+ clean_message = (message or "").strip()
269
+ if not clean_message:
270
+ return "", current_history
271
+
272
+ next_history = [*current_history, {"role": "user", "content": clean_message}]
273
+ context = _companion_context(current, collection, current_history, position)
274
+ answer = _companion_answer(clean_message, current, context)
275
+
276
+ return "", [*next_history, _assistant_message(answer)]
277
+
278
+
279
+ companion_reply = myco_reply
280
+
281
+
282
+ def _companion_answer(message, current, context):
283
+ answer = _local_pipeline_answer(message, current, context)
284
+ if answer is None:
285
+ return _fallback_companion_answer(current, context)
286
+ return answer
287
+
288
+
289
+ def _local_pipeline_answer(message, current, context):
290
+ """
291
+ Ask the locally loaded transformers pipeline for Myco's reply.
292
+ Returns None if the pipeline is unavailable so the caller can fall back.
293
+ """
294
+ pipe = _get_pipeline()
295
+ if not pipe:
296
+ return None
297
+
298
+ messages = _build_messages(message, current, context)
299
+ try:
300
+ outputs = pipe(
301
+ messages,
302
+ max_new_tokens=90,
303
+ temperature=0.8,
304
+ do_sample=True,
305
+ )
306
+
307
+ print("========== MYCO OUTPUT ==========")
308
+ print(outputs)
309
+ print("=================================")
310
+
311
+ last = outputs[0]["generated_text"][-1]
312
+
313
+ # The pipeline returns the full generated_text list; the last entry is
314
+ # the new assistant turn added by the model.
315
+ last = outputs[0]["generated_text"][-1]
316
+ # last is either a dict {"role": "assistant", "content": "..."}
317
+ # or a plain string depending on the transformers version.
318
+ if isinstance(last, dict):
319
+ return (last.get("content") or "").strip() or None
320
+ return str(last).strip() or None
321
+ except Exception as exc:
322
+ print(f"[Myco] Pipeline inference error: {exc}")
323
+ return None
324
+
325
+
326
+ def _discovery_reaction(current, collection):
327
+ """Return Myco's first reaction to a new discovery."""
328
+ mushroom = mushroom_from_state(current)
329
+ context = _companion_context(current, collection, welcome_history(), None)
330
+ prompt = (
331
+ f"The player just discovered {mushroom.name}. "
332
+ f"Rarity: {mushroom.rarity}. Clue: {current.get('clue', 'No clue yet')}. "
333
+ f"Forest event: {current.get('event_title', 'Strange Omen')}. "
334
+ f"Encounter: {current.get('encounter', 'No creature yet')}. "
335
+ f"Mystery: {current.get('mystery_title', 'The Wrong Memory')}. "
336
+ "React as Myco with emotion, make the moment memorable, and invite a meaningful choice."
337
+ )
338
+ answer = _local_pipeline_answer(prompt, current, context)
339
+ if answer:
340
+ return answer
341
+
342
+ return (
343
+ f"We found a {mushroom.name} during {current.get('event_title', 'a strange omen')}! "
344
+ f"Myco feels {current.get('myco_mood', 'curious')} and whispers: "
345
+ f"{current.get('encounter', 'the forest is watching us')} "
346
+ f"Mystery: {current.get('mystery_title', 'The Wrong Memory')} — "
347
+ f"{current.get('mystery_reveal', 'the forest is hiding a memory')}. "
348
+ f"Clue: {current.get('clue', 'the forest is trying to tell us something')} "
349
+ "Choose Study, Pick, or Follow Whisper."
350
+ )
351
+
352
+
353
+ def _fallback_companion_answer(current, context):
354
+ """Return the deterministic fallback companion answer using game context."""
355
+ if current:
356
+ mushroom = mushroom_from_state(current)
357
+ emotion = context["emotion"]
358
+ memory = context["memory"]
359
+ pattern = context["pattern"]
360
+ return (
361
+ f"I feel **{emotion}** about **{mushroom.name}**. {mushroom.lore} "
362
+ f"{pattern} {memory} "
363
+ "The edible, magic, and danger fields are still unknown and uncertain, so stay close to me: "
364
+ "Study it or Follow Whisper before risking anything."
365
+ )
366
+ return (
367
+ "I am right beside you. Move through the clearing and search again — "
368
+ "I will watch for memories, poison omens, and clues you might miss."
369
+ )
370
+
371
+
372
+ def _build_messages(message, current, context):
373
+ """Build the chat messages list for the local pipeline."""
374
+ mushroom_context = "No mushroom is currently discovered."
375
+ if current:
376
+ mushroom = mushroom_from_state(current)
377
+ mushroom_context = (
378
+ f"Current mushroom: {mushroom.name}. "
379
+ f"Rarity: {mushroom.rarity}. "
380
+ f"Edible: {mushroom.edible}. "
381
+ f"Magic: {mushroom.magic}. "
382
+ f"Danger: {mushroom.danger}. "
383
+ f"Lore: {mushroom.lore}. "
384
+ f"Clue: {current.get('clue', 'Unknown')}. "
385
+ f"Secret signal: {current.get('secret', 'Unknown')}"
386
+ )
387
+
388
+ companion_context = (
389
+ f"Location: {context['location']}. "
390
+ f"Weather: {context['weather']}. "
391
+ f"Quest: {context['quest']}. "
392
+ f"Quest progress: {context['progress']}. "
393
+ f"Rare finds: {context['rare_finds']}. "
394
+ f"Notable event: {context['notable_event']}. "
395
+ f"Emotion: {context['emotion']}. "
396
+ f"Pattern Myco noticed: {context['pattern']}. "
397
+ f"Memory: {context['memory']}. "
398
+ f"Recent conversation: {context['conversation']}"
399
+ )
400
+
401
+ return [
402
+ {
403
+ "role": "system",
404
+ "content": (
405
+ f"{MYCO_CHARACTER_SPEC}\n\n"
406
+ f"Current location: {context['location']}\n"
407
+ f"Weather: {context['weather']}\n"
408
+ f"Rare mushrooms found: {context['rare_finds']}\n"
409
+ f"Quest progress: {context['progress']}\n\n"
410
+ "Stay in character as Myco. Do not act like a generic assistant. "
411
+ "Never claim an unknown mushroom is safe to eat. "
412
+ "Answer in 1-3 short sentences."
413
+ ),
414
+ },
415
+ {
416
+ "role": "user",
417
+ "content": f"{mushroom_context} {companion_context} Player asks: {message}",
418
+ },
419
+ ]
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Game actions
424
+ # ---------------------------------------------------------------------------
425
+
426
+
427
+ def collect_current(current=None, collection=None, history=None):
428
+ """Add the current mushroom to the MycoDex if it is not already collected."""
429
+ current_collection = _safe_collection(collection)
430
+ current_history = _safe_history(history)
431
+
432
+ if current is None:
433
+ return current_collection, _append_assistant(
434
+ current_history,
435
+ "Let's discover a mushroom before updating the MycoDex.",
436
+ )
437
+
438
+ if collection_contains(current_collection, current["name"]):
439
+ return current_collection, _append_assistant(
440
+ current_history,
441
+ f"{current['name']} is already safe in your MycoDex.",
442
+ )
443
+
444
+ score_delta = _score_value(current)
445
+ scored_current = _with_reward_state(current, current_collection, score_delta)
446
+ updated_collection = [*current_collection, scored_current]
447
+ secret_note = current.get("secret", "The MycoDex pages rustle like leaves.")
448
+ return updated_collection, _append_assistant(
449
+ current_history,
450
+ (
451
+ f"+{score_delta} spores! Added {current['name']} to the MycoDex. "
452
+ f"{secret_note} Score: {scored_current['score_total']}."
453
+ ),
454
+ )
455
+
456
+
457
+ def pick_current(current=None, collection=None, history=None):
458
+ """Pick the current mushroom like a game item; poisonous picks end the run."""
459
+ current_collection = _safe_collection(collection)
460
+ current_history = _safe_history(history)
461
+ if current is None:
462
+ return current_collection, None, _append_assistant(
463
+ current_history,
464
+ "Move Myco, search a clearing, then pick the mushroom that appears.",
465
+ )
466
+
467
+ if _is_poisonous(current):
468
+ score_total = max(0, _score_collection(current_collection) + POISON_SCORE_PENALTY)
469
+ game_over = {
470
+ **current,
471
+ "danger": "Poisonous",
472
+ "game_over": "Yes",
473
+ "health": "0",
474
+ "score_delta": str(POISON_SCORE_PENALTY),
475
+ "score_total": str(score_total),
476
+ "reward_text": "Poison hit! -25 spores · Health 0/3",
477
+ }
478
+ return current_collection, game_over, _append_assistant(
479
+ current_history,
480
+ (
481
+ f"💀 GAME OVER — {current['name']} was poisonous. "
482
+ f"{POISON_SCORE_PENALTY} spores and health dropped to 0/3. "
483
+ "Myco pulls you back as the clearing goes silent."
484
+ ),
485
+ )
486
+
487
+ score_delta = _score_value(current)
488
+ picked = _with_reward_state(
489
+ {**current, "picked": "Yes", "danger": "No obvious poison"},
490
+ current_collection,
491
+ score_delta,
492
+ )
493
+ if collection_contains(current_collection, picked["name"]):
494
+ return current_collection, picked, _append_assistant(
495
+ current_history,
496
+ f"{picked['name']} was already picked and logged in the MycoDex.",
497
+ )
498
+
499
+ updated_collection = [*current_collection, picked]
500
+ bonus = _reward_text(picked, score_delta)
501
+ return updated_collection, picked, _append_assistant(
502
+ current_history,
503
+ (
504
+ f"🍄 {bonus} Picked {picked['name']} safely! "
505
+ f"Score: {picked['score_total']} · Health {picked['health']}/3. "
506
+ "Myco tucks the clue into the MycoDex."
507
+ ),
508
+ )
509
+
510
+
511
+ def follow_whisper(current=None, collection=None, history=None):
512
+ """Follow the active story omen for a risk-reward discovery moment."""
513
+ current_collection = _safe_collection(collection)
514
+ current_history = _safe_history(history)
515
+ if current is None:
516
+ return None, _append_assistant(
517
+ current_history,
518
+ "Myco cups one ear. First search a clearing; the forest only whispers near mushrooms.",
519
+ )
520
+
521
+ if _is_poisonous(current) and current.get("studied") != "Yes":
522
+ game_over = {**current, "danger": "Poisonous", "game_over": "Yes"}
523
+ return game_over, _append_assistant(
524
+ current_history,
525
+ (
526
+ "💀 GAME OVER — the whisper belonged to poison. "
527
+ "Myco freezes, then remembers this exact mistake from a dream."
528
+ ),
529
+ )
530
+
531
+ mystery = _mystery_thread(len(current_collection) + 1)
532
+ memory = _memory_fragment(current, len(current_collection), mystery)
533
+ revealed = {
534
+ **current,
535
+ **mystery,
536
+ "memory_fragment": memory,
537
+ "whisper_followed": "Yes",
538
+ "secret": f"{current.get('secret', 'A secret stirs.')} {memory}",
539
+ }
540
+ return revealed, _append_assistant(
541
+ current_history,
542
+ (
543
+ f"🌌 Myco follows the whisper and gasps: {memory} "
544
+ "This is not just collecting mushrooms anymore — it is recovering Myco's lost forest."
545
+ ),
546
+ )
547
+
548
+
549
+ def study_current(current=None, history=None):
550
+ """Study the current mushroom to reveal one cautious field observation."""
551
+ current_history = _safe_history(history)
552
+ if current is None:
553
+ return None, _append_assistant(
554
+ current_history,
555
+ "Let's find a mushroom before we study anything.",
556
+ )
557
+
558
+ studied = {**current, "magic": _study_hint(current["rarity"]), "studied": "Yes"}
559
+ clue = studied.get("clue", "The clue is still forming in the moss.")
560
+ message = (
561
+ f"I studied the {current['name']} closely. New MycoDex note: "
562
+ f"magic is now marked as **{studied['magic']}**. Clue: {clue} "
563
+ "Still no tasting without certainty!"
564
+ )
565
+ return studied, _append_assistant(current_history, message)
566
+
567
+
568
+ def eat_current(current=None, history=None):
569
+ """Handle the Eat choice safely with Myco's companion personality."""
570
+ current_history = _safe_history(history)
571
+ if current is None:
572
+ return _append_assistant(
573
+ current_history,
574
+ "Let's discover a mushroom before making snack decisions.",
575
+ )
576
+
577
+ mushroom = mushroom_from_state(current)
578
+ message = (
579
+ f"Myco gently blocks the {mushroom.name}. "
580
+ f"Its edible field is still **{mushroom.edible}**, "
581
+ "so the winning move is to study or collect it instead of eating it."
582
+ )
583
+ return _append_assistant(current_history, message)
584
+
585
+
586
+ # ---------------------------------------------------------------------------
587
+ # Context helpers
588
+ # ---------------------------------------------------------------------------
589
+
590
+
591
+ def _companion_context(current, collection, history, position):
592
+ """Summarize the current journey so Myco replies like a present companion."""
593
+ current_collection = _safe_collection(collection)
594
+ count = len(current_collection)
595
+ rare_names = [
596
+ entry.get("name", "Unknown")
597
+ for entry in current_collection
598
+ if entry.get("rarity") != "Common"
599
+ ]
600
+ location = _location_label(position)
601
+ quest = MYSTERY_QUESTION
602
+ if current:
603
+ progress = (
604
+ f"{current.get('mystery_title', 'The Wrong Memory')} — "
605
+ f"{current.get('mystery_next', 'keep exploring')}"
606
+ )
607
+ notable_event = (
608
+ f"{current.get('event_emoji', '✨')} {current.get('event_title', 'Strange Omen')}: "
609
+ f"{current.get('encounter', 'the forest is watching')}"
610
+ )
611
+ emotion = current.get("myco_mood", "curious")
612
+ memory = current.get("memory_fragment") or current.get(
613
+ "mystery_reveal",
614
+ "Myco feels the forest tugging at an old memory.",
615
+ )
616
+ pattern = _pattern_notice(current, current_collection)
617
+ else:
618
+ progress = "No active discovery yet; search a clearing to start the mystery."
619
+ notable_event = "Myco is listening before the first omen."
620
+ emotion = "curious"
621
+ memory = "Myco remembers a path that is not on the map yet."
622
+ pattern = "No pattern yet, but the trees are leaning inward."
623
+
624
+ return {
625
+ "location": location,
626
+ "quest": quest,
627
+ "progress": progress,
628
+ "rare_finds": ", ".join(rare_names) if rare_names else "none yet",
629
+ "weather": notable_event,
630
+ "notable_event": notable_event,
631
+ "emotion": emotion,
632
+ "memory": memory,
633
+ "pattern": pattern,
634
+ "conversation": _recent_conversation(history),
635
+ "collection_count": str(count),
636
+ }
637
+
638
+
639
+ def _location_label(position):
640
+ """Return a small in-world label for the grid position."""
641
+ if position is None:
642
+ return "center clearing"
643
+ x, y = position
644
+ names = {
645
+ (0, 0): "northwest root arch",
646
+ (1, 0): "northern fern gate",
647
+ (2, 0): "northeast moon stump",
648
+ (0, 1): "western moss path",
649
+ (1, 1): "center clearing",
650
+ (2, 1): "eastern glow log",
651
+ (0, 2): "southwest rain hollow",
652
+ (1, 2): "southern spore bridge",
653
+ (2, 2): "southeast elder shade",
654
+ }
655
+ return names.get((int(x), int(y)), "unknown clearing")
656
+
657
+
658
+ def _pattern_notice(current, collection):
659
+ """Return one pattern Myco can comment on from the journey so far."""
660
+ if current.get("game_over") == "Yes":
661
+ return "The same poison warning appeared before Myco could say it aloud."
662
+ if current.get("rarity") == "Legendary":
663
+ return "Legendary spores bend toward the MycoDex instead of the moon."
664
+ if current.get("poison") == "Yes":
665
+ return "Poison omens arrive with black-winged moths and sudden silence."
666
+ if len(collection) >= 3:
667
+ return "Every third clue makes the MycoDex feel warmer, like it is alive."
668
+ if current.get("event_title") == "Moonlit Sporefall":
669
+ return "Moonlit spores keep behaving like memories instead of seeds."
670
+ return "The clues are not random; they keep pointing back to Myco's forgotten path."
671
+
672
+
673
+ def _recent_conversation(history):
674
+ """Return a compact summary of recent player/Myco conversation turns."""
675
+ turns = []
676
+
677
+ for entry in _safe_history(history)[-6:]:
678
+ role = entry.get("role", "assistant")
679
+ content = entry.get("content", "")
680
+
681
+ # 1. Handle Gradio's list format (multimodal messages)
682
+ if isinstance(content, list):
683
+ pieces = []
684
+ for item in content:
685
+ if isinstance(item, dict):
686
+ # Extract text from dict, fallback to empty string if it's purely a file
687
+ if "text" in item:
688
+ pieces.append(item["text"])
689
+ elif isinstance(item, str):
690
+ pieces.append(item)
691
+ elif isinstance(item, tuple):
692
+ pieces.append("[Attachment]")
693
+ content = " ".join(pieces)
694
+
695
+ # 2. Handle Gradio's tuple format (old-style direct file uploads)
696
+ elif isinstance(content, tuple):
697
+ content = "[Attachment]"
698
+
699
+ # 3. Force to string and safely clean up
700
+ content = str(content).replace("\n", " ").strip()
701
+
702
+ if content:
703
+ turns.append(f"{role}: {content[:120]}")
704
+
705
+ return " | ".join(turns) if turns else "No conversation yet."
706
+
707
+
708
+ # ---------------------------------------------------------------------------
709
+ # Story / mystery helpers
710
+ # ---------------------------------------------------------------------------
711
+
712
+
713
+ def _mystery_clue(current, collection_count):
714
+ rarity = current.get("rarity", "Common")
715
+ name = current.get("name", "this mushroom")
716
+ clue = RARITY_CLUES.get(rarity, RARITY_CLUES["Common"])
717
+ if collection_count == 0:
718
+ return f"First clue: {name} marks the beginning of the hidden Spore Door trail."
719
+ if rarity == "Legendary":
720
+ return f"Legendary clue: {name} reveals a glowing Elder Map fragment."
721
+ if rarity == "Rare":
722
+ return f"Rare clue: {name} points Myco toward a secret ring of mushrooms."
723
+ return clue
724
+
725
+
726
+ def _mystery_thread(collection_count):
727
+ chapter = MYSTERY_CHAPTERS[0]
728
+ next_chapter = None
729
+ for candidate in MYSTERY_CHAPTERS:
730
+ if collection_count >= candidate["threshold"]:
731
+ chapter = candidate
732
+ elif next_chapter is None:
733
+ next_chapter = candidate
734
+
735
+ if next_chapter is None:
736
+ next_line = "The Impossible Bloom is visible. Follow the whisper for the final truth."
737
+ else:
738
+ remaining = next_chapter["threshold"] - collection_count
739
+ next_line = f"{remaining} clue discovery until {next_chapter['title']}."
740
+
741
+ return {
742
+ "mystery_title": chapter["title"],
743
+ "mystery_question": MYSTERY_QUESTION,
744
+ "mystery_progress": chapter["clue"],
745
+ "mystery_reveal": chapter["reveal"],
746
+ "mystery_next": next_line,
747
+ }
748
+
749
+
750
+ def _story_moment(current, collection_count):
751
+ event = FOREST_EVENTS[collection_count % len(FOREST_EVENTS)]
752
+ encounter = _encounter_for(current, collection_count)
753
+ return {
754
+ "event_title": event["title"],
755
+ "event_emoji": event["emoji"],
756
+ "event_choice": event["choice"],
757
+ "event_consequence": event["consequence"],
758
+ "encounter": encounter,
759
+ "myco_mood": event["mood"],
760
+ "objective": IMPOSSIBLE_OBJECTIVE,
761
+ }
762
+
763
+
764
+ def _encounter_for(current, collection_count):
765
+ if current.get("rarity") == "Legendary":
766
+ return "A mushroom guardian bows and calls Myco by an old royal name."
767
+ if _is_poisonous(current):
768
+ return "A black-winged moth lands on Myco's cap: a poison omen."
769
+ if current.get("rarity") == "Rare" or collection_count in (2, 5, 8):
770
+ return RARE_ENCOUNTERS[collection_count % len(RARE_ENCOUNTERS)]
771
+ return "No creature appears, but the trees lean closer to hear Myco think."
772
+
773
+
774
+ def _memory_fragment(current, collection_count, mystery):
775
+ name = current.get("name", "this mushroom")
776
+ reveal = mystery.get("mystery_reveal", "The forest remembers Myco.")
777
+ if mystery.get("mystery_title") == "The Impossible Bloom":
778
+ return reveal
779
+ if current.get("rarity") == "Legendary":
780
+ return f"Memory fragment: {name} once grew beside Myco's missing home. {reveal}"
781
+ if current.get("rarity") == "Rare":
782
+ return f"Memory fragment: {name} hums the name of a door under the roots. {reveal}"
783
+ if collection_count >= 3:
784
+ return f"Memory fragment: {name} points toward the Impossible Mushroom. {reveal}"
785
+ return f"Memory fragment: {name} shows Myco a path they do not remember walking. {reveal}"
786
+
787
+
788
+ def _secret_signal(current, collection_count):
789
+ rarity = current.get("rarity", "Common")
790
+ if rarity == "Legendary":
791
+ return "The MycoDex page blooms with a star-shaped seal."
792
+ if rarity == "Rare":
793
+ return "A silver margin note appears beside the sketch."
794
+ if collection_count + 1 in (1, 3, 7, 12):
795
+ return "A hidden path symbol flickers at the page corner."
796
+ return "The page smells faintly of rain and moss."
797
+
798
+
799
+ # ---------------------------------------------------------------------------
800
+ # Scoring helpers
801
+ # ---------------------------------------------------------------------------
802
+
803
+
804
+ def _score_value(current):
805
+ return RARITY_SCORE_VALUES.get(current.get("rarity", "Common"), 10)
806
+
807
+
808
+ def _score_collection(collection):
809
+ total = 0
810
+ for entry in _safe_collection(collection):
811
+ if entry.get("game_over") == "Yes":
812
+ continue
813
+ total += int(entry.get("score_delta") or _score_value(entry))
814
+ return max(0, total)
815
+
816
+
817
+ def _health_for_state(current, collection):
818
+ if current.get("game_over") == "Yes":
819
+ return 0
820
+ poison_penalties = sum(
821
+ 1 for entry in _safe_collection(collection) if entry.get("game_over") == "Yes"
822
+ )
823
+ return max(0, PLAYER_STARTING_HEALTH - poison_penalties)
824
+
825
+
826
+ def _with_reward_state(current, collection, score_delta):
827
+ score_total = _score_collection(collection) + score_delta
828
+ return {
829
+ **current,
830
+ "score_delta": str(score_delta),
831
+ "score_total": str(max(0, score_total)),
832
+ "health": str(_health_for_state(current, collection)),
833
+ "reward_text": _reward_text(current, score_delta),
834
+ }
835
+
836
+
837
+ def _reward_text(current, score_delta):
838
+ if score_delta < 0:
839
+ return f"Poison penalty {score_delta} spores"
840
+ rarity = current.get("rarity", "Common")
841
+ if rarity == "Legendary":
842
+ return f"LEGENDARY +{score_delta} spores · Impossible Bloom surges"
843
+ if rarity == "Rare":
844
+ return f"RARE +{score_delta} spores · special discovery unlocked"
845
+ if score_delta > 0:
846
+ return f"+{score_delta} spores"
847
+ return "No spores scored yet"
848
+
849
+
850
+ # ---------------------------------------------------------------------------
851
+ # Safety / utility helpers
852
+ # ---------------------------------------------------------------------------
853
+
854
+
855
+ def _is_poisonous(current):
856
+ return current.get("name", "") in POISONOUS_MUSHROOMS or current.get("danger") == "Poisonous"
857
+
858
+
859
+ def _study_hint(rarity):
860
+ if rarity == "Legendary":
861
+ return "Strong, mysterious aura"
862
+ if rarity == "Rare":
863
+ return "Faint magical trace"
864
+ return "Tiny spore shimmer"
865
+
866
+
867
+ def _assistant_message(content):
868
+ return {"role": "assistant", "content": content}
869
+
870
+
871
+ def _append_assistant(history, content):
872
+ return [*_safe_history(history), _assistant_message(content)]
873
+
874
+
875
+ def _safe_collection(collection):
876
+ return list(collection or [])
877
+
878
+
879
+ def _safe_history(history):
880
+ return list(history or welcome_history())
881
+