byte-vortex commited on
Commit
ac97b38
·
verified ·
1 Parent(s): 21f3232

Delete game/actions.py

Browse files
Files changed (1) hide show
  1. game/actions.py +0 -881
game/actions.py DELETED
@@ -1,881 +0,0 @@
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
-