byte-vortex commited on
Commit
0ef26ee
·
verified ·
1 Parent(s): b8afcca

Delete game/not--engine.py

Browse files
Files changed (1) hide show
  1. game/not--engine.py +0 -808
game/not--engine.py DELETED
@@ -1,808 +0,0 @@
1
- """
2
- Core Myco gameplay — LLM is the primary game engine.
3
- """
4
- import os
5
- import re
6
- import random
7
- import threading
8
- import traceback
9
- import torch
10
- from transformers import AutoModelForCausalLM, AutoTokenizer
11
-
12
- # Local imports
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
- # Define device (CPU by default)
23
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
24
- print(f"[Myco] Engine initializing on: {DEVICE.upper()}")
25
-
26
- def _get_model_and_tokenizer():
27
- global _model, _tokenizer
28
- if _model is not None and _tokenizer is not None:
29
- return _model, _tokenizer
30
-
31
- with _lock:
32
- if _model is not None and _tokenizer is not None:
33
- return _model, _tokenizer
34
-
35
- model_id = os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
36
- token = os.getenv("HF_BUILD_SMALL_HACKATHON_TOKEN")
37
-
38
- try:
39
- print(f"[Myco] Loading model: {model_id}...")
40
- _tokenizer = AutoTokenizer.from_pretrained(model_id, token=token)
41
-
42
- # Load with CPU-safe settings
43
- _model = AutoModelForCausalLM.from_pretrained(
44
- model_id,
45
- token=token,
46
- torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
47
- device_map=DEVICE,
48
- trust_remote_code=True
49
- )
50
- return _model, _tokenizer
51
- except Exception as exc:
52
- print(f"[Myco] Load error: {exc}")
53
- return None, None
54
-
55
-
56
- def _get_pipeline():
57
- """Bridge function to resolve NameError for the pipeline."""
58
- model, tokenizer = _get_model_and_tokenizer()
59
- return model
60
-
61
-
62
- def _run_pipeline(pipe_ignored, messages):
63
- """Handles inference on the active device (CPU or GPU)."""
64
- model, tokenizer = _get_model_and_tokenizer()
65
- if model is None:
66
- return "The forest is silent. (Model loading failed)"
67
-
68
- formatted_prompt = tokenizer.apply_chat_template(
69
- messages, tokenize=False, add_generation_prompt=True
70
- )
71
-
72
- inputs = tokenizer(formatted_prompt, return_tensors="pt").to(DEVICE)
73
-
74
- with torch.no_grad():
75
- outputs = model.generate(
76
- **inputs,
77
- max_new_tokens=256,
78
- do_sample=True,
79
- temperature=0.7,
80
- pad_token_id=tokenizer.eos_token_id
81
- )
82
-
83
- input_length = inputs.input_ids.shape[1]
84
- generated_tokens = outputs[0][input_length:]
85
- return tokenizer.decode(generated_tokens, skip_special_tokens=True)
86
-
87
-
88
- # --- Ensure you have this global list accessible in engine.py ---
89
- POISONOUS_MUSHROOM_NAMES = {
90
- "Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle",
91
- }
92
-
93
- # ---------------------------------------------------------------------------
94
- # Template Helper (Fixes the Gemma TemplateError)
95
- # ---------------------------------------------------------------------------
96
- def _format_messages_for_template(system_prompt: str, user_prompt: str, history_turns: list = None) -> list:
97
- """
98
- Formats messages securely for models with strict chat templates (like Gemma).
99
- - Starts the conversation with a 'user' turn containing the system prompt.
100
- - Maps 'assistant' role tags directly to 'model'.
101
- - Implements alternative processing to merge consecutive same-role text blocks.
102
- """
103
- # Gemma doesn't support the 'system' role, so we inject it as the first 'user' prompt
104
- raw_turns = [{"role": "user", "content": system_prompt}]
105
-
106
- if history_turns:
107
- for entry in history_turns:
108
- role = entry.get("role", "assistant")
109
- if role == "assistant":
110
- role = "model"
111
- elif role == "system":
112
- continue # Strip extraneous system tags
113
-
114
- content = entry.get("content", "")
115
- if isinstance(content, str) and content.strip():
116
- raw_turns.append({"role": role, "content": content})
117
-
118
- # Add the current payload action
119
- raw_turns.append({"role": "user", "content": user_prompt})
120
-
121
- # Deduplicate consecutive roles (guarantees perfect strict user/model rotation)
122
- alternated_turns = []
123
- for turn in raw_turns:
124
- if alternated_turns and alternated_turns[-1]["role"] == turn["role"]:
125
- alternated_turns[-1]["content"] += f"\n\n{turn['content']}"
126
- else:
127
- alternated_turns.append(turn)
128
-
129
- return alternated_turns
130
-
131
- # ---------------------------------------------------------------------------
132
- # Game Constants
133
- # ---------------------------------------------------------------------------
134
- JSON_PROMPT_SUFFIX = (
135
- "\n\nRespond with a single JSON object only. No prose before or after it. "
136
- 'Example: {"action":"pick","target":"Ruby Knuckle","thought":"It seems safe."}'
137
- )
138
-
139
- RARITY_WEIGHTS = {"Common": 64, "Rare": 24, "Legendary": 8}
140
- RARITY_SCORE = {"Common": 10, "Rare": 35, "Legendary": 100}
141
- PLAYER_HEALTH = 3
142
- POISON_PENALTY = -25
143
- POISONOUS = {"Ghost Gill", "Pepper Pixie", "Ruby Knuckle", "Clockwork Chanterelle"}
144
-
145
- SYSTEM_PROMPT = """You are Myco, a tiny sentient mushroom companion and forest guide.
146
- You are curious, warm, slightly anxious about poisonous mushrooms, and deeply connected
147
- to the forest mystery. You speak in short, vivid sentences. You never break character.
148
- You react emotionally to discoveries — with awe for Legendary mushrooms, caution for
149
- poisonous ones, and gentle wonder for Common ones. You hint at the deeper mystery of
150
- the vanished forest and the MycoDex that seems to remember things it shouldn't."""
151
-
152
- FOREST_EVENTS = [
153
- {"title": "A Quiet Clearing", "emoji": "🌿", "mood": "calm"},
154
- {"title": "Wind Between Trees", "emoji": "🕯️", "mood": "afraid"},
155
- ]
156
-
157
- MYSTERY_CHAPTERS = [
158
- {"threshold": 0, "title": "The Wrong Memory", "clue": "Myco recognises the path before you move."},
159
- {"threshold": 1, "title": "The Traveler's Song", "clue": "A stranger's lullaby appears in the MycoDex margin."},
160
- {"threshold": 2, "title": "The Door Under Roots","clue": "Rare spores point to a door nobody built."},
161
- {"threshold": 3, "title": "The MycoDex Seed", "clue": "The MycoDex grows warm like a living cap."},
162
- {"threshold": 5, "title": "The Impossible Bloom","clue": "The Impossible Mushroom was never outside the book."},
163
- ]
164
-
165
- RARITY_CLUES = {
166
- "Common": "The cap leans toward the path, like it wants to be remembered.",
167
- "Rare": "Silver spores circle it in a pattern only old forest stories describe.",
168
- "Legendary": "The whole clearing goes quiet — this mushroom hides part of the Elder Map.",
169
- }
170
-
171
-
172
- # ---------------------------------------------------------------------------
173
- # Utility: extract JSON or return text
174
- # ---------------------------------------------------------------------------
175
- def _extract_json_or_text(generated_text: str) -> str | None:
176
- if not generated_text:
177
- return None
178
- text = str(generated_text).strip()
179
-
180
- simple_matches = re.findall(r"\{.*?\}|\[.*?\]", text, flags=re.DOTALL)
181
- if simple_matches:
182
- for candidate in reversed(simple_matches):
183
- try:
184
- parsed = json.loads(candidate)
185
- return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
186
- except Exception:
187
- continue
188
-
189
- return text or None
190
-
191
-
192
- # ---------------------------------------------------------------------------
193
- # Status
194
- # ---------------------------------------------------------------------------
195
- def companion_model_id():
196
- return os.getenv("MYCO_MODEL_ID", DEFAULT_MODEL_ID)
197
-
198
-
199
- def companion_status():
200
- pipe = _get_pipeline()
201
- model = companion_model_id()
202
- if pipe:
203
- return f"🧠 Myco AI active ({model})"
204
- return f"⚠️ Myco AI fallback mode ({model} failed)"
205
-
206
-
207
- def hf_companion_status():
208
- return companion_status()
209
-
210
-
211
- # ---------------------------------------------------------------------------
212
- # LLM call — single-turn
213
- # ---------------------------------------------------------------------------
214
- def _llm(prompt: str, context: dict | None = None) -> str | None:
215
- pipe = _get_pipeline()
216
- if not pipe:
217
- return None
218
-
219
- ctx = context or {}
220
- mushroom_line = ""
221
- if ctx.get("name"):
222
- poison_flag = " ⚠️ POISONOUS" if ctx.get("name") in POISONOUS else ""
223
- mushroom_line = (
224
- f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')} rarity){poison_flag}. "
225
- f"Habitat: {ctx.get('habitat','?')}. Lore: {ctx.get('lore','?')}. "
226
- f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}. "
227
- f"Danger: {ctx.get('danger','Unknown')}."
228
- )
229
-
230
- collection_line = f"MycoDex entries: {ctx.get('collection_count', 0)}."
231
- mystery_line = f"Active mystery chapter: {ctx.get('mystery_title', 'The Wrong Memory')}."
232
- score_line = f"Player score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3."
233
-
234
- system = f"{SYSTEM_PROMPT}\n\n{mushroom_line}\n{collection_line}\n{mystery_line}\n{score_line}"
235
-
236
- # Safe layout handling for strict model templates
237
- messages = _format_messages_for_template(system, prompt + JSON_PROMPT_SUFFIX)
238
-
239
- try:
240
- outputs = _run_pipeline(pipe, messages)
241
- print("========== MYCO OUTPUT ==========")
242
- print(outputs)
243
- print("=================================")
244
-
245
- if isinstance(outputs, list) and outputs:
246
- first = outputs[0]
247
- generated = first.get("generated_text", "") if isinstance(first, dict) else str(first)
248
- else:
249
- generated = str(outputs)
250
-
251
- if isinstance(generated, list):
252
- last = generated[-1]
253
- text = last.get("content") if isinstance(last, dict) else str(last)
254
- else:
255
- text = str(generated)
256
-
257
- return _extract_json_or_text(text)
258
- except Exception as exc:
259
- print(f"[Myco] Inference error: {exc}")
260
- traceback.print_exc()
261
- return None
262
-
263
-
264
- # ---------------------------------------------------------------------------
265
- # LLM call — multi-turn chat
266
- # ---------------------------------------------------------------------------
267
- def _llm_with_history(history: list, user_message: str, context: dict) -> str | None:
268
- """Chat function with strict ANTI-JSON guardrails."""
269
- pipe = _get_pipeline()
270
- if not pipe:
271
- return None
272
-
273
- ctx = context or {}
274
- mushroom_line = ""
275
- if ctx.get("name"):
276
- poison_flag = " ⚠️ POISONOUS" if ctx.get("name") in POISONOUS_MUSHROOM_NAMES else ""
277
- mushroom_line = (
278
- f"Current mushroom: {ctx['name']} ({ctx.get('rarity','?')}){poison_flag}. "
279
- f"Lore: {ctx.get('lore','?')}. "
280
- f"Edible: {ctx.get('edible','Unknown')}. Magic: {ctx.get('magic','Unknown')}."
281
- )
282
-
283
- # The new strict anti-JSON prompt
284
- system = (
285
- f"{SYSTEM_PROMPT}\n\n"
286
- f"{mushroom_line}\n"
287
- f"MycoDex entries: {ctx.get('collection_count', 0)}. "
288
- f"Mystery: {ctx.get('mystery_title', 'The Wrong Memory')}. "
289
- f"Score: {ctx.get('score', 0)} spores. Health: {ctx.get('health', 3)}/3.\n\n"
290
- "--- CRITICAL CHAT INSTRUCTION ---\n"
291
- "You are chatting organically with the player. Speak ONLY in regular text dialogue.\n"
292
- "❌ DO NOT output any JSON structures, game actions, keys, or thoughts blocks (like {\"action\": ...}).\n"
293
- "❌ NEVER wrap your dialogue in a ```json markdown block.\n"
294
- "Just talk to them like a supportive mushroom companion!"
295
- )
296
-
297
- messages = _format_messages_for_template(system, user_message, history[-6:])
298
-
299
- try:
300
- reply = _run_pipeline(pipe, messages)
301
- print("========== MYCO OUTPUT ==========")
302
- print(reply)
303
- print("=================================")
304
- return reply
305
- except Exception as exc:
306
- print(f"[Myco] Inference error: {exc}")
307
- traceback.print_exc()
308
- return None
309
-
310
-
311
- # ---------------------------------------------------------------------------
312
- # Context builder
313
- # ---------------------------------------------------------------------------
314
- def _ctx(current: dict | None, collection: list) -> dict:
315
- count = len(collection)
316
- chapter = MYSTERY_CHAPTERS[0]
317
- for c in MYSTERY_CHAPTERS:
318
- if count >= c["threshold"]:
319
- chapter = c
320
- score = _score_collection(collection)
321
- health = _health(current, collection)
322
- ctx: dict = {
323
- "collection_count": count,
324
- "mystery_title": chapter["title"],
325
- "mystery_clue": chapter["clue"],
326
- "score": score,
327
- "health": health,
328
- }
329
- if current:
330
- ctx.update({
331
- "name": current.get("name", ""),
332
- "rarity": current.get("rarity", "Common"),
333
- "habitat": current.get("habitat", ""),
334
- "lore": current.get("lore", ""),
335
- "edible": current.get("edible", "Unknown"),
336
- "magic": current.get("magic", "Unknown"),
337
- "danger": current.get("danger", "Unknown"),
338
- })
339
- return ctx
340
-
341
-
342
- # ---------------------------------------------------------------------------
343
- # Fallbacks
344
- # ---------------------------------------------------------------------------
345
- def _fallback_discover(current: dict) -> str:
346
- name = current.get("name", "something")
347
- rarity = current.get("rarity", "Common")
348
- poison = current.get("name", "") in POISONOUS
349
- if poison:
350
- return f"Wait — {name}! I've seen this before... something feels very wrong. Don't touch it yet."
351
- if rarity == "Legendary":
352
- return f"Oh! Oh! A {name}! The whole clearing just went silent. This is from the Elder Map!"
353
- if rarity == "Rare":
354
- return f"A {name}... I can feel it humming. Something rare is here — maybe magical."
355
- return f"A {name}! Found near {current.get('habitat','the forest')}. Let me sense it first."
356
-
357
-
358
- def _fallback_pick(current: dict) -> str:
359
- if _is_poisonous(current):
360
- return "💀 That was poisonous! I tried to stop you... the forest goes dark."
361
- return f"Got {current.get('name','it')}! +{RARITY_SCORE.get(current.get('rarity','Common'),10)} spores!"
362
-
363
-
364
- def _fallback_study(current: dict) -> str:
365
- return f"I studied it carefully. Magic field updated. The clue: {RARITY_CLUES.get(current.get('rarity','Common'), '')}"
366
-
367
-
368
- def _fallback_collect(current: dict) -> str:
369
- return f"Added {current.get('name','it')} to the MycoDex! The pages feel warmer."
370
-
371
-
372
- def _fallback_whisper(current: dict) -> str:
373
- return "I followed the whisper... and remembered a path I've never walked. The mystery deepens."
374
-
375
-
376
- def _fallback_chat(current: dict | None) -> str:
377
- if current:
378
- return f"I feel something strange about {current.get('name','this')}... stay close to me."
379
- return "The forest is full of secrets. Move to a clearing and search — I'll watch for danger."
380
-
381
-
382
- # ---------------------------------------------------------------------------
383
- # Mushroom helpers
384
- # ---------------------------------------------------------------------------
385
- def _choose_mushroom(catalog):
386
- """Safely picks a mushroom, with a fallback if the catalog is empty."""
387
- # 1. Ensure catalog is not empty
388
- if not catalog:
389
- print("CRITICAL: Mushroom catalog is empty! Using a fallback mushroom.")
390
- # Create a simple fallback object if none exist
391
- class FallbackMushroom:
392
- def __init__(self):
393
- self.name = "Common Moss Cap"
394
- self.rarity = "common"
395
- self.lore = "A simple mushroom that grew when the forest ran out of ideas."
396
- return FallbackMushroom()
397
-
398
- # 2. Safely get mushrooms and weights
399
- mushrooms = list(catalog.values())
400
- weights = [getattr(m, 'weight', 1.0) for m in mushrooms]
401
-
402
- # 3. Perform the choice
403
- try:
404
- return random.choices(mushrooms, weights=weights, k=1)[0]
405
- except Exception as e:
406
- print(f"Error in random.choices: {e}")
407
- return mushrooms[0] # Return the first available as a last resort
408
-
409
- def _is_poisonous(current: dict) -> bool:
410
- return current.get("name", "") in POISONOUS or current.get("danger") == "Poisonous"
411
-
412
-
413
- def _score_value(current: dict) -> int:
414
- return RARITY_SCORE.get(current.get("rarity", "Common"), 10)
415
-
416
-
417
- def _score_collection(collection: list) -> int:
418
- total = 0
419
- for e in collection:
420
- if e.get("game_over") == "Yes":
421
- continue
422
- total += int(e.get("score_delta") or _score_value(e))
423
- return max(0, total)
424
-
425
-
426
- def _health(current: dict | None, collection: list) -> int:
427
- if current and current.get("game_over") == "Yes":
428
- return 0
429
- deaths = sum(1 for e in collection if e.get("game_over") == "Yes")
430
- return max(0, PLAYER_HEALTH - deaths)
431
-
432
-
433
- def _mystery_state(count: int) -> dict:
434
- chapter, next_ch = MYSTERY_CHAPTERS[0], None
435
- for c in MYSTERY_CHAPTERS:
436
- if count >= c["threshold"]:
437
- chapter = c
438
- elif next_ch is None:
439
- next_ch = c
440
- next_line = (
441
- f"{next_ch['threshold'] - count} discoveries until {next_ch['title']}."
442
- if next_ch else "The Impossible Bloom is near. Follow the whisper."
443
- )
444
- return {
445
- "mystery_title": chapter["title"],
446
- "mystery_clue": chapter["clue"],
447
- "mystery_next": next_line,
448
- }
449
-
450
-
451
- def _story_event(count: int) -> dict:
452
- return FOREST_EVENTS[count % len(FOREST_EVENTS)]
453
-
454
-
455
- def _build_current(mushroom, collection: list) -> dict:
456
- count = len(collection)
457
- current = mushroom.to_dict()
458
- current["poison"] = "Yes" if mushroom.name in POISONOUS else "No"
459
- current["score_total"] = str(_score_collection(collection))
460
- current["health"] = str(_health(current, collection))
461
- current["score_delta"] = "0"
462
- current["clue"] = RARITY_CLUES.get(mushroom.rarity, RARITY_CLUES["Common"])
463
- if count == 0:
464
- current["clue"] = f"First clue: {mushroom.name} marks the beginning of the Spore Door trail."
465
- event = _story_event(count)
466
- current.update({
467
- "event_title": event["title"],
468
- "event_emoji": event["emoji"],
469
- "myco_mood": event["mood"],
470
- "reward_text": "Discover, then pick or collect.",
471
- })
472
- current.update(_mystery_state(count))
473
- return current
474
-
475
-
476
- def _append(history: list, role: str, content: str) -> list:
477
- return [*history, {"role": role, "content": content}]
478
-
479
-
480
- def _safe_history(h) -> list:
481
- return list(h or welcome_history())
482
-
483
-
484
- def _safe_collection(c) -> list:
485
- return list(c or [])
486
-
487
-
488
- # ---------------------------------------------------------------------------
489
- # Public game actions
490
- # ---------------------------------------------------------------------------
491
- def discover_mushroom(catalog):
492
- """Instantly spawns 2-4 mushrooms with robust fallback fields to prevent UI crashes."""
493
- num_mushrooms = random.randint(2, 4)
494
- active_mushrooms = []
495
- used_tiles = set()
496
-
497
- # Keep track of the last mushroom instance so we can return a valid object
498
- # to any legacy code expecting a real mushroom instance
499
- last_mushroom_obj = None
500
-
501
- for _ in range(num_mushrooms):
502
- mushroom = _choose_mushroom(catalog)
503
- last_mushroom_obj = mushroom
504
-
505
- mush_x, mush_y = random.randint(0, 2), random.randint(0, 2)
506
- while (mush_x, mush_y) in used_tiles:
507
- mush_x, mush_y = random.randint(0, 2), random.randint(0, 2)
508
- used_tiles.add((mush_x, mush_y))
509
-
510
- # We fill EVERY possible key that dex_markdown or status bars might read
511
- active_mushrooms.append({
512
- "name": mushroom.name,
513
- "rarity": mushroom.rarity,
514
- "habitat": getattr(mushroom, "habitat", "Glow Undergrowth"),
515
- "lore": getattr(mushroom, "lore", "A rapid micro-sprout spawned during a bloom."),
516
- "edible": getattr(mushroom, "edible", "Unknown"),
517
- "magic": getattr(mushroom, "magic", "Unknown"),
518
- "danger": getattr(mushroom, "danger", "Unknown"),
519
- "poison": "Yes" if mushroom.name in POISONOUS_MUSHROOM_NAMES else "No",
520
- "mush_x": mush_x,
521
- "mush_y": mush_y,
522
- "picked": "No",
523
- "clue": "Harvested instantly!",
524
- "studied": "No"
525
- })
526
-
527
- # Build a current state dictionary packed with fallback HUD variables
528
- current = {
529
- "name": "Mushroom Patch",
530
- "rarity": "Multiple",
531
- "poison": "No",
532
- "active_mushrooms": active_mushrooms,
533
- "game_over": "No",
534
- "picked": "No",
535
- "studied": "No",
536
- "encounter": f"Boom! {num_mushrooms} mushrooms sprouted! Move Myco to collect them!",
537
- "event_title": "Spore Influx",
538
- "event_emoji": "✨",
539
- "mystery_title": "The Wrong Memory",
540
- "score_total": "0",
541
- "health": "3",
542
- "reward_text": "Run around the map to clear the board!"
543
- }
544
-
545
- # Return the real mushroom object instead of None to prevent 'NoneType' errors
546
- return last_mushroom_obj, current, []
547
-
548
-
549
- def myco_reply(message=None, history=None, current=None, collection=None, position=None):
550
- """Player chats with Myco. LLM responds in character with full context."""
551
- print("MYCO_REPLY CALLED, message:", repr(message))
552
- hist = _safe_history(history)
553
- coll = _safe_collection(collection)
554
- clean = (message or "").strip()
555
- if not clean:
556
- return "", hist
557
-
558
- ctx = _ctx(current, coll)
559
- reply = _llm_with_history(hist, clean, ctx)
560
- if not reply:
561
- reply = _fallback_chat(current)
562
-
563
- return "", _append(hist, "user", clean) + [{"role": "assistant", "content": reply}]
564
-
565
-
566
- companion_reply = myco_reply
567
-
568
-
569
- def collect_current(current=None, collection=None, history=None):
570
- """Collect mushroom into MycoDex. LLM narrates the entry."""
571
- coll = _safe_collection(collection)
572
- hist = _safe_history(history)
573
-
574
- if current is None:
575
- return coll, _append(hist, "assistant", "We need to find a mushroom first!")
576
-
577
- if collection_contains(coll, current["name"]):
578
- return coll, _append(hist, "assistant", f"{current['name']} is already in the MycoDex!")
579
-
580
- score_delta = _score_value(current)
581
- score_total = _score_collection(coll) + score_delta
582
- collected = {
583
- **current,
584
- "score_delta": str(score_delta),
585
- "score_total": str(score_total),
586
- "health": str(_health(current, coll)),
587
- "reward_text": f"+{score_delta} spores",
588
- }
589
- updated_coll = [*coll, collected]
590
- ctx = _ctx(current, coll)
591
-
592
- prompt = (
593
- f"The player just added {current['name']} ({current.get('rarity','Common')}) to the MycoDex! "
594
- f"+{score_delta} spores. Total score: {score_total}. "
595
- f"MycoDex now has {len(updated_coll)} entries. "
596
- "Celebrate this moment. Add a small lore detail or mystery hint."
597
- )
598
- reply = _llm(prompt, ctx) or _fallback_collect(current)
599
- return updated_coll, _append(hist, "assistant", reply)
600
-
601
-
602
- def pick_current(current=None, collection=None, history=None):
603
- """Pick mushroom as game item. Poison = game over. LLM narrates dramatically."""
604
- coll = _safe_collection(collection)
605
- hist = _safe_history(history)
606
-
607
- if current is None:
608
- return coll, None, _append(hist, "assistant", "Find a mushroom first before picking!")
609
-
610
- ctx = _ctx(current, coll)
611
-
612
- if _is_poisonous(current):
613
- score_total = max(0, _score_collection(coll) + POISON_PENALTY)
614
- game_over = {
615
- **current,
616
- "danger": "Poisonous",
617
- "game_over": "Yes",
618
- "health": "0",
619
- "score_delta": str(POISON_PENALTY),
620
- "score_total": str(score_total),
621
- "reward_text": f"Poison! {POISON_PENALTY} spores · Game Over",
622
- }
623
- prompt = (
624
- f"DRAMATIC MOMENT: The player picked {current['name']} which is POISONOUS! "
625
- f"Game Over! Score drops by 25 to {score_total}. Health → 0. "
626
- "React with shock, sadness, and a dramatic farewell. Make it memorable."
627
- )
628
- reply = _llm(prompt, ctx) or _fallback_pick(current)
629
- return coll, game_over, _append(hist, "assistant", f"💀 {reply}")
630
-
631
- score_delta = _score_value(current)
632
- score_total = _score_collection(coll) + score_delta
633
- picked = {
634
- **current,
635
- "picked": "Yes",
636
- "danger": "Safe",
637
- "score_delta": str(score_delta),
638
- "score_total": str(score_total),
639
- "health": str(_health(current, coll)),
640
- "reward_text": f"+{score_delta} spores",
641
- }
642
-
643
- if collection_contains(coll, picked["name"]):
644
- return coll, picked, _append(hist, "assistant", f"{picked['name']} already picked!")
645
-
646
- updated_coll = [*coll, picked]
647
- prompt = (
648
- f"The player safely picked {current['name']} ({current.get('rarity','Common')})! "
649
- f"+{score_delta} spores. Total: {score_total}. "
650
- "Celebrate! Make it feel like a platformer power-up moment."
651
- )
652
- reply = _llm(prompt, ctx) or _fallback_pick(current)
653
- return updated_coll, picked, _append(hist, "assistant", f"🍄 {reply}")
654
-
655
-
656
- def follow_whisper(current=None, collection=None, history=None):
657
- """Follow the forest whisper. LLM reveals mystery fragments."""
658
- coll = _safe_collection(collection)
659
- hist = _safe_history(history)
660
-
661
- if current is None:
662
- return None, _append(hist, "assistant",
663
- "Myco cups one ear. The forest only whispers near mushrooms — search a clearing first.")
664
-
665
- ctx = _ctx(current, coll)
666
-
667
- if _is_poisonous(current) and current.get("studied") != "Yes":
668
- game_over = {
669
- **current,
670
- "danger": "Poisonous",
671
- "game_over": "Yes",
672
- "health": "0",
673
- "score_total": str(max(0, _score_collection(coll) + POISON_PENALTY)),
674
- }
675
- prompt = (
676
- f"The player followed a whisper but it led to POISON from {current['name']}! Game Over! "
677
- "React with horror and a haunting mystery revelation."
678
- )
679
- reply = _llm(prompt, ctx) or "💀 The whisper belonged to poison... Myco screams."
680
- return game_over, _append(hist, "assistant", reply)
681
-
682
- mystery = _mystery_state(len(coll) + 1)
683
- prompt = (
684
- f"The player followed a forest whisper near {current.get('name','a mushroom')}. "
685
- f"Mystery chapter revealed: {mystery['mystery_title']}. Clue: {mystery['mystery_clue']}. "
686
- "Reveal this mystery fragment dramatically. "
687
- "Make Myco gasp or tremble. Hint that the MycoDex is alive and regrowing the lost forest."
688
- )
689
- reply = _llm(prompt, ctx) or _fallback_whisper(current)
690
- revealed = {**current, **mystery, "whisper_followed": "Yes"}
691
- return revealed, _append(hist, "assistant", f"🌌 {reply}")
692
-
693
-
694
- def study_current(current=None, history=None):
695
- """Study mushroom. LLM gives a careful field observation."""
696
- hist = _safe_history(history)
697
-
698
- if current is None:
699
- return None, _append(hist, "assistant", "Nothing to study yet — find a mushroom first!")
700
-
701
- prompt = (
702
- f"Study the mushroom {current.get('name','unknown')} carefully. "
703
- "Provide a concise field observation and one hint about its magic or danger."
704
- )
705
- reply = _llm(prompt, _ctx(current, [])) or _fallback_study(current)
706
- return reply, _append(hist, "assistant", reply)
707
-
708
-
709
- def eat_current(current=None, collection=None, history=None):
710
- """Eating mushrooms raw is always blocked. This dynamic response is now fully driven by Myco AI."""
711
- coll = _safe_collection(collection)
712
- hist = _safe_history(history)
713
-
714
- if current is None:
715
- return coll, _append(hist, "assistant", "There's nothing here to eat!")
716
-
717
- ctx = _ctx(current, coll)
718
- prompt = (
719
- f"The player is attempting to directly EAT the raw mushroom: {current.get('name','unknown')}. "
720
- "This is highly forbidden, unsafe, and unidentified! React as Myco with absolute dynamic panic, "
721
- "gently scold them in character for trying something so dangerous, and assertively tell them "
722
- "they need to Study it instead of eating it!"
723
- )
724
-
725
- # Classic static text fallback just in case inference drops
726
- fallback_reply = (
727
- f"No no no! {current.get('name','That')} could be dangerous raw! "
728
- "I'll never let you eat an unidentified mushroom. Study it first!"
729
- )
730
-
731
- reply = _llm(prompt, ctx) or fallback_reply
732
- return coll, _append(hist, "assistant", reply)
733
-
734
-
735
- def check_auto_pickup(new_pos_x, new_pos_y, current, collection):
736
- """Arcade collision rules that cleanly update score text safely."""
737
- if not current or current.get("game_over") == "Yes" or current.get("picked") == "Yes":
738
- return current, collection
739
-
740
- mushrooms = current.get("active_mushrooms", [])
741
- all_picked = True
742
-
743
- for m in mushrooms:
744
- if m.get("picked") == "No":
745
- # Check if Myco stepped on this coordinate
746
- if new_pos_x == m["mush_x"] and new_pos_y == m["mush_y"]:
747
- if m["poison"] == "Yes":
748
- current["game_over"] = "Yes"
749
- current["encounter"] = f"💀 Oh no! Myco stepped directly onto a toxic {m['name']}!"
750
- current["event_title"] = "Poison Shock!"
751
- return current, collection
752
- else:
753
- m["picked"] = "Yes"
754
- current["encounter"] = f"💥 Blip! Collected a rare {m['name']}!"
755
-
756
- # Safely tick up scores inside the state without crashing types
757
- current_score = int(current.get("score_total", "0")) + 15
758
- current["score_total"] = str(current_score)
759
- current["reward_text"] = f"+15 Spores from {m['name']}!"
760
-
761
- collection.append(m)
762
- else:
763
- # This one hasn't been collected yet
764
- all_picked = False
765
-
766
- if all_picked and mushrooms:
767
- current["picked"] = "Yes"
768
- current["encounter"] = "Area clear! Click Search Clearing to spawn the next cluster."
769
- current["event_title"] = "Clearing Purified"
770
-
771
- return current, collection
772
-
773
-
774
- # Myco narrative
775
- def get_myco_narrative(current=None):
776
- # 1. Personality Prompt - Made more aggressive
777
- prompt = (
778
- "You are Myco, a tiny, emotional mushroom companion. "
779
- "Speak in one or two short sentences about the forest. "
780
- "ABSOLUTELY NO JSON. NO BRACKETS. NO THOUGHT BLOCKS. "
781
- "Speak like a person, not a computer."
782
- )
783
-
784
- context = {"name": current.get("name", "mystery"), "rarity": current.get("rarity", "common")} if current else {}
785
-
786
- # 2. Get the response
787
- reply = _llm(prompt, context=context)
788
-
789
- if not reply:
790
- return "The forest feels deep today... ✨"
791
-
792
- # 3. Aggressive Clean-up: If it contains JSON, strip it away
793
- # This regex removes anything inside {} or []
794
- cleaned_reply = re.sub(r'\{.*\}|\[.*\]', '', reply)
795
-
796
- # Remove keys like "thought": or "thought"
797
- cleaned_reply = re.sub(r'"thought":|thought:', '', cleaned_reply, flags=re.IGNORECASE)
798
-
799
- # Remove extra quotes and whitespace
800
- cleaned_reply = cleaned_reply.replace('"', '').strip()
801
-
802
- # If the clean-up resulted in an empty string, fallback
803
- if not cleaned_reply:
804
- return "The forest feels deep today... ✨"
805
-
806
- return cleaned_reply
807
-
808
-