synth-ai 0.2.13.dev1__py3-none-any.whl → 0.2.13.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

Files changed (226) hide show
  1. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +12 -1
  2. examples/swe/task_app/grpo_swe_mini.py +55 -26
  3. examples/swe/task_app/hosted/rollout.py +40 -0
  4. examples/swe/task_app/hosted/test_service.py +5 -6
  5. examples/task_apps/TESTING.md +275 -0
  6. examples/task_apps/__init__.py +0 -0
  7. examples/task_apps/crafter/__init__.py +0 -0
  8. examples/task_apps/crafter/task_app/__init__.py +2 -0
  9. examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter.py +18 -13
  10. examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter_task_app.py +1 -1
  11. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/policy.py +60 -4
  12. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/policy_routes.py +25 -3
  13. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/rollout.py +10 -0
  14. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_service.py +5 -6
  15. examples/task_apps/dev/pokemon_emerald/__init__.py +2 -0
  16. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/README.md +811 -0
  17. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/__init__.py +120 -0
  18. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/action.py +160 -0
  19. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/memory.py +155 -0
  20. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/perception.py +69 -0
  21. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/planning.py +96 -0
  22. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/simple.py +1502 -0
  23. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/system_prompt.py +4 -0
  24. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/grab_map.py +68 -0
  25. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/manual.py +216 -0
  26. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/__init__.py +35 -0
  27. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emerald_utils.py +631 -0
  28. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emulator.py +1544 -0
  29. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/enums.py +1428 -0
  30. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/memory_reader.py +4848 -0
  31. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/types.py +41 -0
  32. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/utils.py +298 -0
  33. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pyproject.toml +95 -0
  34. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/run.py +204 -0
  35. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/__init__.py +0 -0
  36. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/app.py +2152 -0
  37. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/client.py +429 -0
  38. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/frame_server.py +155 -0
  39. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/README.md +78 -0
  40. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/__init__.py +0 -0
  41. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/run_tests.py +122 -0
  42. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_direct.py +76 -0
  43. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_prompts.py +413 -0
  44. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_battle_state_formatting.py +204 -0
  45. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection.py +133 -0
  46. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection_comprehensive.py +229 -0
  47. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_direct_agent_emulator.py +300 -0
  48. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_fps_adjustment_pytest.py +205 -0
  49. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_direct.py +200 -0
  50. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_transition.py +284 -0
  51. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_map_ground_truth_comparison.py +468 -0
  52. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_memory_map.py +575 -0
  53. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_server_map_validation.py +311 -0
  54. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_torchic_state.py +259 -0
  55. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/__init__.py +0 -0
  56. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/anticheat.py +372 -0
  57. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/checkpoint.py +296 -0
  58. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/error_handler.py +275 -0
  59. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/get_local_ip.py +22 -0
  60. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/helpers.py +44 -0
  61. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/llm_logger.py +514 -0
  62. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_formatter.py +415 -0
  63. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher.py +1763 -0
  64. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher_singleton.py +33 -0
  65. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_trimmer.py +106 -0
  66. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_visualizer.py +334 -0
  67. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/ocr_dialogue.py +1020 -0
  68. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/recording.py +188 -0
  69. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/state_formatter.py +1481 -0
  70. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/vlm.py +862 -0
  71. examples/task_apps/dev/pokemon_emerald/modal_app.py +114 -0
  72. examples/task_apps/dev/pokemon_emerald/task_app/README.md +81 -0
  73. examples/task_apps/dev/pokemon_emerald/task_app/__init__.py +6 -0
  74. examples/task_apps/dev/pokemon_emerald/task_app/pokemon_emerald.py +685 -0
  75. examples/task_apps/enron/__init__.py +1 -0
  76. examples/task_apps/enron/eval_groq_qwen32.toml +16 -0
  77. examples/task_apps/enron/task_app/README.md +14 -0
  78. examples/task_apps/enron/task_app/__init__.py +1 -0
  79. examples/task_apps/enron/task_app/grpo_enron.py +906 -0
  80. examples/task_apps/enron/task_app/grpo_enron_task_app.py +146 -0
  81. examples/task_apps/enron/tests/__init__.py +2 -0
  82. examples/task_apps/enron/tests/conftest.py +115 -0
  83. examples/task_apps/enron/tests/integration/__init__.py +2 -0
  84. examples/task_apps/enron/tests/integration/test_enron_eval.py +177 -0
  85. examples/task_apps/enron/tests/integration/test_enron_rollout.py +135 -0
  86. examples/task_apps/enron/tests/unit/__init__.py +2 -0
  87. examples/task_apps/enron/tests/unit/test_enron_environment.py +126 -0
  88. examples/task_apps/math/__init__.py +0 -0
  89. examples/{rl/task_app → task_apps/math}/math_single_step.py +19 -10
  90. examples/task_apps/pokemon_battle/__init__.py +2 -0
  91. examples/task_apps/pokemon_battle/modal_app.py +104 -0
  92. examples/task_apps/pokemon_battle/task_app/README.md +68 -0
  93. examples/task_apps/pokemon_battle/task_app/__init__.py +6 -0
  94. examples/task_apps/pokemon_battle/task_app/pokemon_showdown.py +932 -0
  95. examples/task_apps/pokemon_red/README.md +357 -0
  96. examples/task_apps/pokemon_red/__init__.py +3 -0
  97. examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +225 -0
  98. examples/task_apps/pokemon_red/pallet_town_rl_config.toml +73 -0
  99. examples/task_apps/pokemon_red/task_app.py +606 -0
  100. examples/task_apps/pokemon_red/test_pallet_town_rewards.py +191 -0
  101. examples/task_apps/sokoban/README.md +307 -0
  102. examples/task_apps/sokoban/__init__.py +3 -0
  103. examples/task_apps/sokoban/eval_groq_qwen32.toml +16 -0
  104. examples/task_apps/sokoban/eval_openai_gpt5.toml +16 -0
  105. examples/task_apps/sokoban/task_app.py +1058 -0
  106. examples/task_apps/sokoban/tests/__init__.py +2 -0
  107. examples/task_apps/sokoban/tests/conftest.py +113 -0
  108. examples/task_apps/sokoban/tests/integration/__init__.py +2 -0
  109. examples/task_apps/sokoban/tests/integration/test_sokoban_eval.py +57 -0
  110. examples/task_apps/sokoban/tests/integration/test_sokoban_rollout.py +198 -0
  111. examples/task_apps/sokoban/tests/unit/__init__.py +2 -0
  112. examples/task_apps/sokoban/tests/unit/test_sokoban_environment.py +114 -0
  113. examples/task_apps/verilog/__init__.py +1 -0
  114. examples/task_apps/verilog/eval_groq_qwen32b.toml +20 -0
  115. examples/task_apps/verilog/task_app/README.md +12 -0
  116. examples/task_apps/verilog/task_app/__init__.py +1 -0
  117. examples/task_apps/verilog/task_app/grpo_verilog.py +931 -0
  118. examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +145 -0
  119. examples/task_apps/verilog/tests/__init__.py +2 -0
  120. examples/task_apps/verilog/tests/conftest.py +115 -0
  121. examples/task_apps/verilog/tests/integration/__init__.py +2 -0
  122. examples/task_apps/verilog/tests/integration/test_verilog_eval.py +179 -0
  123. examples/task_apps/verilog/tests/integration/test_verilog_rollout.py +55 -0
  124. examples/task_apps/verilog/tests/unit/__init__.py +2 -0
  125. examples/task_apps/verilog/tests/unit/test_verilog_scoring.py +118 -0
  126. examples/vlm/crafter_openai_vlm_agent.py +4 -4
  127. examples/vlm/run_crafter_vlm_benchmark.py +4 -4
  128. examples/workflows/__init__.py +0 -0
  129. examples/workflows/math_rl/__init__.py +0 -0
  130. examples/workflows/math_rl/download_dataset.py +80 -0
  131. synth_ai/__init__.py +2 -2
  132. synth_ai/api/train/builders.py +25 -11
  133. synth_ai/api/train/cli.py +12 -6
  134. synth_ai/api/train/configs/__init__.py +10 -10
  135. synth_ai/api/train/configs/rl.py +5 -4
  136. synth_ai/api/train/configs/sft.py +4 -3
  137. synth_ai/api/train/env_resolver.py +5 -2
  138. synth_ai/api/train/supported_algos.py +10 -5
  139. synth_ai/api/train/utils.py +7 -4
  140. synth_ai/cli/__init__.py +7 -51
  141. synth_ai/cli/_storage.py +4 -3
  142. synth_ai/cli/_validate_task_app.py +11 -0
  143. synth_ai/cli/balance.py +4 -3
  144. synth_ai/cli/calc.py +2 -2
  145. synth_ai/cli/demo.py +14 -7
  146. synth_ai/cli/legacy_root_backup.py +1 -1
  147. synth_ai/cli/rl_demo.py +8 -7
  148. synth_ai/cli/root.py +0 -97
  149. synth_ai/cli/task_apps.py +1707 -186
  150. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +28 -16
  151. synth_ai/environments/examples/enron/engine.py +7 -2
  152. synth_ai/environments/examples/enron/environment.py +68 -0
  153. synth_ai/environments/examples/red/engine.py +27 -0
  154. synth_ai/environments/examples/red/engine_helpers/memory_map.py +7 -0
  155. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_progression.py +477 -0
  156. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +32 -0
  157. synth_ai/environments/examples/red/environment.py +60 -0
  158. synth_ai/environments/examples/sokoban/taskset.py +116 -0
  159. synth_ai/environments/examples/verilog/engine.py +30 -4
  160. synth_ai/evals/client.py +58 -61
  161. synth_ai/jobs/client.py +16 -4
  162. synth_ai/judge_schemas.py +16 -16
  163. synth_ai/py.typed +0 -0
  164. synth_ai/task/__init__.py +14 -5
  165. synth_ai/task/contracts.py +124 -38
  166. synth_ai/task/proxy.py +48 -56
  167. synth_ai/task/rubrics/__init__.py +53 -0
  168. synth_ai/task/rubrics/loaders.py +133 -0
  169. synth_ai/task/rubrics/models.py +57 -0
  170. synth_ai/task/rubrics/scoring.py +113 -0
  171. synth_ai/{rubrics/validators.py → task/rubrics/strict.py} +53 -30
  172. synth_ai/task/server.py +8 -7
  173. synth_ai/task/validators.py +269 -6
  174. synth_ai/tracing_v3/decorators.py +7 -3
  175. synth_ai/tracing_v3/replica_sync.py +4 -4
  176. synth_ai/tracing_v3/serialization.py +5 -5
  177. synth_ai/tracing_v3/trace_utils.py +317 -0
  178. synth_ai/tracing_v3/turso/native_manager.py +3 -3
  179. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.13.dev2.dist-info}/METADATA +4 -1
  180. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.13.dev2.dist-info}/RECORD +214 -101
  181. examples/agora_ex/README_MoE.md +0 -224
  182. examples/agora_ex/__init__.py +0 -7
  183. examples/agora_ex/agora_ex.py +0 -65
  184. examples/agora_ex/agora_ex_task_app.py +0 -590
  185. examples/agora_ex/configs/rl_lora_qwen3_moe_2xh200.toml +0 -121
  186. examples/agora_ex/reward_fn_grpo-human.py +0 -129
  187. examples/agora_ex/system_prompt_CURRENT.md +0 -63
  188. examples/agora_ex/task_app/agora_ex_task_app.py +0 -590
  189. examples/agora_ex/task_app/reward_fn_grpo-human.py +0 -129
  190. examples/agora_ex/task_app/system_prompt_CURRENT.md +0 -63
  191. synth_ai/rubrics/__init__.py +0 -22
  192. synth_ai/task/rubrics.py +0 -219
  193. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/README.md +0 -0
  194. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/README.md +0 -0
  195. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/__init__.py +0 -0
  196. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/branching.py +0 -0
  197. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/environment_routes.py +0 -0
  198. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/__init__.py +0 -0
  199. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/__init__.py +0 -0
  200. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/app.py +0 -0
  201. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/environment.py +0 -0
  202. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/react_agent.py +0 -0
  203. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/shared.py +0 -0
  204. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/tools.py +0 -0
  205. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/hosted_app.py +0 -0
  206. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/__init__.py +0 -0
  207. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/openai_client.py +0 -0
  208. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/main.py +0 -0
  209. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/registry.py +0 -0
  210. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/__init__.py +0 -0
  211. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/volume.py +0 -0
  212. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_agents.py +0 -0
  213. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/utils.py +0 -0
  214. /examples/{rl/task_app → task_apps/math}/README.md +0 -0
  215. /examples/{rl/task_app → task_apps/math}/math_task_app.py +0 -0
  216. /examples/{rl → workflows/math_rl}/configs/eval_base_qwen.toml +0 -0
  217. /examples/{rl → workflows/math_rl}/configs/eval_rl_qwen.toml +0 -0
  218. /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen.toml +0 -0
  219. /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen17.toml +0 -0
  220. /examples/{rl → workflows/math_rl}/configs/rl_from_ft_qwen.toml +0 -0
  221. /examples/{rl → workflows/math_rl}/run_eval.py +0 -0
  222. /examples/{rl → workflows/math_rl}/run_rl_and_save.py +0 -0
  223. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.13.dev2.dist-info}/WHEEL +0 -0
  224. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.13.dev2.dist-info}/entry_points.txt +0 -0
  225. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.13.dev2.dist-info}/licenses/LICENSE +0 -0
  226. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.13.dev2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1544 @@
1
+ import logging
2
+ import time
3
+ import threading
4
+ import queue
5
+ import tempfile
6
+ import json
7
+ import os
8
+ import shutil
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import Dict, Any, Optional, List
12
+ import numpy as np
13
+ from PIL import Image
14
+
15
+ import mgba.core
16
+ import mgba.log
17
+ import mgba.image
18
+ from mgba._pylib import ffi, lib
19
+
20
+ from .memory_reader import PokemonEmeraldReader
21
+ from utils.state_formatter import save_persistent_world_map, load_persistent_world_map
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # some acknowledgement to https://github.com/dvruette/pygba
26
+
27
+ class MilestoneTracker:
28
+ """Persistent milestone tracking system integrated with emulator"""
29
+
30
+ def __init__(self, filename: str = None):
31
+ # Setup cache directory
32
+ self.cache_dir = ".pokeagent_cache"
33
+ os.makedirs(self.cache_dir, exist_ok=True)
34
+
35
+ # Use cache folder for runtime milestone file
36
+ if filename is None:
37
+ filename = os.path.join(self.cache_dir, "milestones_progress.json")
38
+ self.filename = filename # Runtime cache file (always in cache directory)
39
+ self.loaded_state_milestones_file = None # Track if we loaded from a state-specific file
40
+ self.milestones = {}
41
+ self.latest_milestone = None
42
+ self.latest_split_time = "00:00:00"
43
+ # Don't automatically load from file - only load when explicitly requested
44
+
45
+ def load_from_file(self):
46
+ """Load milestone progress from file"""
47
+ try:
48
+ if os.path.exists(self.filename):
49
+ with open(self.filename, 'r') as f:
50
+ data = json.load(f)
51
+ self.milestones = data.get('milestones', {})
52
+
53
+ # Determine the latest completed milestone based on timestamps
54
+ latest_timestamp = 0
55
+ latest_milestone_id = None
56
+ for milestone_id, milestone_data in self.milestones.items():
57
+ if milestone_data.get('completed', False):
58
+ timestamp = milestone_data.get('timestamp', 0)
59
+ if timestamp > latest_timestamp:
60
+ latest_timestamp = timestamp
61
+ latest_milestone_id = milestone_id
62
+
63
+ # Set the latest milestone if we found one
64
+ if latest_milestone_id:
65
+ self.latest_milestone = latest_milestone_id
66
+ self.latest_split_time = self.milestones[latest_milestone_id].get('split_formatted', '00:00:00')
67
+ logger.info(f"Latest milestone from file: {latest_milestone_id}")
68
+
69
+ logger.info(f"Loaded {len(self.milestones)} milestone records from {self.filename}")
70
+ else:
71
+ logger.info(f"No existing milestone file found, starting fresh")
72
+ self.milestones = {}
73
+ except Exception as e:
74
+ logger.warning(f"Error loading milestones from file: {e}")
75
+ self.milestones = {}
76
+
77
+ def save_to_file(self):
78
+ """Save milestone progress to file"""
79
+ try:
80
+ data = {
81
+ 'milestones': self.milestones,
82
+ 'last_updated': time.time(),
83
+ 'version': '1.0'
84
+ }
85
+ with open(self.filename, 'w') as f:
86
+ json.dump(data, f, indent=2)
87
+ logger.debug(f"Saved milestone progress to {self.filename}")
88
+ except Exception as e:
89
+ logger.warning(f"Error saving milestones to file: {e}")
90
+
91
+ def mark_completed(self, milestone_id: str, timestamp: float = None):
92
+ """Mark a milestone as completed and log split time"""
93
+ if timestamp is None:
94
+ timestamp = time.time()
95
+
96
+ if milestone_id not in self.milestones or not self.milestones[milestone_id].get('completed', False):
97
+ # Calculate split time from previous milestone or start
98
+ split_time = self._calculate_split_time(milestone_id, timestamp)
99
+
100
+ self.milestones[milestone_id] = {
101
+ 'completed': True,
102
+ 'timestamp': timestamp,
103
+ 'first_completed': timestamp,
104
+ 'split_time': split_time,
105
+ 'split_formatted': self._format_time(split_time),
106
+ 'total_time': self._calculate_total_time(timestamp),
107
+ 'total_formatted': self._format_time(self._calculate_total_time(timestamp))
108
+ }
109
+
110
+ # Store the latest completed milestone for easy access
111
+ self.latest_milestone = milestone_id
112
+ self.latest_split_time = self._format_time(split_time)
113
+
114
+ logger.info(f"Milestone completed: {milestone_id} (Split: {self._format_time(split_time)})")
115
+ self.save_to_file()
116
+ return True
117
+ return False
118
+
119
+ def is_completed(self, milestone_id: str) -> bool:
120
+ """Check if a milestone is completed"""
121
+ return self.milestones.get(milestone_id, {}).get('completed', False)
122
+
123
+ def get_milestone_data(self, milestone_id: str) -> dict:
124
+ """Get milestone data"""
125
+ return self.milestones.get(milestone_id, {'completed': False, 'timestamp': None})
126
+
127
+ def reset_milestone(self, milestone_id: str):
128
+ """Reset a milestone (for testing)"""
129
+ if milestone_id in self.milestones:
130
+ del self.milestones[milestone_id]
131
+ self.save_to_file()
132
+ logger.info(f"Reset milestone: {milestone_id}")
133
+
134
+ def _calculate_split_time(self, milestone_id: str, timestamp: float) -> float:
135
+ """Calculate split time from previous milestone completion or start"""
136
+ # Define milestone order for split calculation
137
+ milestone_order = [
138
+ # Phase 1: Game Initialization
139
+ "GAME_RUNNING", "PLAYER_NAME_SET", "INTRO_CUTSCENE_COMPLETE",
140
+
141
+ # Phase 2: Tutorial & Starting Town
142
+ "LITTLEROOT_TOWN", "PLAYER_HOUSE_ENTERED", "PLAYER_BEDROOM",
143
+ "RIVAL_HOUSE", "RIVAL_BEDROOM",
144
+
145
+ # Phase 3: Professor Birch & Starter
146
+ "ROUTE_101", "STARTER_CHOSEN", "BIRCH_LAB_VISITED",
147
+
148
+ # Phase 4: Rival
149
+ "OLDALE_TOWN", "ROUTE_103", "RECEIVED_POKEDEX",
150
+
151
+ # Phase 5: Route 102 & Petalburg
152
+ "ROUTE_102", "PETALBURG_CITY", "DAD_FIRST_MEETING", "GYM_EXPLANATION",
153
+
154
+ # Phase 6: Road to Rustboro City
155
+ "ROUTE_104_SOUTH", "PETALBURG_WOODS", "TEAM_AQUA_GRUNT_DEFEATED",
156
+ "ROUTE_104_NORTH", "RUSTBORO_CITY",
157
+
158
+ # Phase 7: First Gym Challenge
159
+ "RUSTBORO_GYM_ENTERED", "ROXANNE_DEFEATED", "FIRST_GYM_COMPLETE",
160
+
161
+ # Badge milestones (tracked separately)
162
+ "STONE_BADGE"
163
+ ]
164
+
165
+ try:
166
+ # Special case for first milestone - split time is 0
167
+ if milestone_id == "GAME_RUNNING":
168
+ return 0.0
169
+
170
+ if milestone_id not in milestone_order:
171
+ # For unlisted milestones, find the most recent completion
172
+ latest_timestamp = 0
173
+ for _, data in self.milestones.items():
174
+ if data.get('completed', False) and data.get('timestamp', 0) > latest_timestamp:
175
+ latest_timestamp = data.get('timestamp', 0)
176
+ return timestamp - latest_timestamp if latest_timestamp > 0 else 0.0
177
+
178
+ # Find the previous milestone in the order
179
+ current_index = milestone_order.index(milestone_id)
180
+
181
+ # Look backwards for the most recent completed milestone
182
+ for i in range(current_index - 1, -1, -1):
183
+ prev_milestone = milestone_order[i]
184
+ if self.is_completed(prev_milestone):
185
+ prev_timestamp = self.milestones[prev_milestone].get('timestamp', 0)
186
+ return timestamp - prev_timestamp
187
+
188
+ # If no previous milestone found, calculate from start if we have GAME_RUNNING
189
+ if self.is_completed("GAME_RUNNING"):
190
+ start_timestamp = self.milestones["GAME_RUNNING"].get('timestamp', 0)
191
+ return timestamp - start_timestamp
192
+
193
+ # Fallback - no split time available
194
+ return 0.0
195
+
196
+ except Exception as e:
197
+ logger.warning(f"Error calculating split time for {milestone_id}: {e}")
198
+ return 0.0
199
+
200
+ def _format_time(self, seconds: float) -> str:
201
+ """Format time in HH:MM:SS format"""
202
+ try:
203
+ hours = int(seconds // 3600)
204
+ minutes = int((seconds % 3600) // 60)
205
+ secs = int(seconds % 60)
206
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
207
+ except:
208
+ return "00:00:00"
209
+
210
+ def _calculate_total_time(self, timestamp: float) -> float:
211
+ """Calculate total time from game start"""
212
+ try:
213
+ if self.is_completed("GAME_RUNNING"):
214
+ start_timestamp = self.milestones["GAME_RUNNING"].get('timestamp', timestamp)
215
+ return timestamp - start_timestamp
216
+ return 0.0
217
+ except:
218
+ return 0.0
219
+
220
+ def get_latest_milestone_info(self) -> tuple:
221
+ """Get the latest milestone information for submission logging
222
+ Returns: (milestone_name, split_time_formatted, total_time_formatted)
223
+ """
224
+ if self.latest_milestone:
225
+ milestone_data = self.milestones.get(self.latest_milestone, {})
226
+ split_formatted = milestone_data.get('split_formatted', '00:00:00')
227
+ total_formatted = milestone_data.get('total_formatted', '00:00:00')
228
+ return (self.latest_milestone, split_formatted, total_formatted)
229
+ return ("NONE", "00:00:00", "00:00:00")
230
+
231
+ def get_all_completed_milestones(self) -> list:
232
+ """Get a list of all completed milestones with their times"""
233
+ completed = []
234
+ for milestone_id, data in self.milestones.items():
235
+ if data.get('completed', False):
236
+ completed.append({
237
+ 'id': milestone_id,
238
+ 'timestamp': data.get('timestamp', 0),
239
+ 'split_time': data.get('split_formatted', '00:00:00'),
240
+ 'total_time': data.get('total_formatted', '00:00:00')
241
+ })
242
+ return sorted(completed, key=lambda x: x['timestamp'])
243
+
244
+ def reset_all(self):
245
+ """Reset all milestones (for testing)"""
246
+ self.milestones = {}
247
+ self.save_to_file()
248
+ logger.info("Reset all milestones")
249
+
250
+ def load_milestones_for_state(self, state_filename: str = None):
251
+ """Load milestones from file, optionally with a specific state filename"""
252
+ if state_filename:
253
+ # If a state filename is provided, try to load milestones from a corresponding file
254
+ # Get the directory and base name of the state file
255
+ state_dir = os.path.dirname(state_filename)
256
+ base_name = os.path.splitext(os.path.basename(state_filename))[0]
257
+ milestone_filename = os.path.join(state_dir, f"{base_name}_milestones.json")
258
+
259
+ # Track that we loaded from a state-specific file
260
+ self.loaded_state_milestones_file = milestone_filename
261
+ logger.info(f"Loading milestones from state-specific file: {milestone_filename}")
262
+
263
+ try:
264
+ # Temporarily change filename to load from state file
265
+ original_filename = self.filename
266
+ self.filename = milestone_filename
267
+ self.load_from_file()
268
+ # Restore runtime cache filename (always in main directory)
269
+ self.filename = original_filename
270
+ logger.info(f"Loaded {len(self.milestones)} milestones from state {state_filename}")
271
+ logger.info(f"Runtime milestone cache will be saved to: {self.filename}")
272
+ except FileNotFoundError:
273
+ logger.info(f"Milestone file not found: {milestone_filename}, starting fresh milestones for this state")
274
+ # Start with empty milestones for this state
275
+ self.milestones = {}
276
+ # Don't create the state file, just use runtime cache
277
+ logger.info(f"Runtime milestone cache will be saved to: {self.filename}")
278
+ except Exception as e:
279
+ logger.error(f"Error loading milestone file {milestone_filename}: {e}")
280
+ # Fall back to default milestone file
281
+ logger.info(f"Using runtime milestone cache: {self.filename}")
282
+ self.load_from_file()
283
+ else:
284
+ # No state filename provided, use default milestone file in cache
285
+ self.loaded_state_milestones_file = None
286
+ self.filename = os.path.join(self.cache_dir, "milestones_progress.json")
287
+ logger.info(f"Loading milestones from default file: {self.filename}")
288
+ self.load_from_file()
289
+
290
+ def save_milestones_for_state(self, state_filename: str = None):
291
+ """Save milestones to file, optionally with a specific state filename"""
292
+ if state_filename:
293
+ # If a state filename is provided, save milestones to a corresponding file
294
+ # Get the directory and base name of the state file
295
+ state_dir = os.path.dirname(state_filename)
296
+ base_name = os.path.splitext(os.path.basename(state_filename))[0]
297
+ milestone_filename = os.path.join(state_dir, f"{base_name}_milestones.json")
298
+
299
+ original_filename = self.filename
300
+ self.filename = milestone_filename
301
+ logger.info(f"Saving {len(self.milestones)} milestones to state-specific file: {milestone_filename}")
302
+
303
+ try:
304
+ self.save_to_file()
305
+ logger.info(f"Successfully saved milestones to {milestone_filename}")
306
+ except Exception as e:
307
+ logger.error(f"Error saving milestone file {milestone_filename}: {e}")
308
+ # Fall back to default milestone file
309
+ self.filename = original_filename
310
+ self.save_to_file()
311
+ return original_filename
312
+ finally:
313
+ # Restore original filename
314
+ self.filename = original_filename
315
+
316
+ return milestone_filename
317
+ else:
318
+ # Save to default milestone file
319
+ logger.info(f"Saving {len(self.milestones)} milestones to default file: {self.filename}")
320
+ self.save_to_file()
321
+ return self.filename
322
+
323
+
324
+ class EmeraldEmulator:
325
+ """emulator wrapper for Pokémon Emerald with headless frame capture and scripted inputs."""
326
+
327
+ def __init__(self, rom_path: str, headless: bool = True, sound: bool = False):
328
+ self.rom_path = rom_path
329
+ self.headless = headless
330
+ self.sound = sound
331
+
332
+ self.gba = None
333
+ self.core = None
334
+ self.width = 240
335
+ self.height = 160
336
+ self.running = False
337
+
338
+ self.frame_queue = queue.Queue(maxsize=10)
339
+ self.current_frame = None
340
+ self.frame_thread = None
341
+
342
+ # Memory reader for accessing game state
343
+ self.memory_reader = None
344
+
345
+ # Memory cache for efficient reading
346
+ self._mem_cache = {}
347
+
348
+ # Setup cache directory
349
+ self.cache_dir = ".pokeagent_cache"
350
+ os.makedirs(self.cache_dir, exist_ok=True)
351
+
352
+ # Milestone tracker for progress tracking (using cache file)
353
+ self.milestone_tracker = MilestoneTracker(os.path.join(self.cache_dir, "milestones_progress.json"))
354
+
355
+ # Dialog state tracking for FPS adjustment
356
+ self._cached_dialog_state = False
357
+ self._last_dialog_check_time = 0
358
+ self._dialog_check_interval = 0.05 # Check dialog state every 50ms (more responsive)
359
+
360
+ # Track currently loaded state file
361
+ self._current_state_file = None
362
+
363
+ # Define key mapping for mgba
364
+ self.KEY_MAP = {
365
+ "a": lib.GBA_KEY_A,
366
+ "b": lib.GBA_KEY_B,
367
+ "start": lib.GBA_KEY_START,
368
+ "select": lib.GBA_KEY_SELECT,
369
+ "up": lib.GBA_KEY_UP,
370
+ "down": lib.GBA_KEY_DOWN,
371
+ "left": lib.GBA_KEY_LEFT,
372
+ "right": lib.GBA_KEY_RIGHT,
373
+ "l": lib.GBA_KEY_L,
374
+ "r": lib.GBA_KEY_R
375
+ }
376
+
377
+ def initialize(self):
378
+ """Load ROM and set up emulator"""
379
+ try:
380
+ # Prevents relentless spamming to stdout by libmgba.
381
+ mgba.log.silence()
382
+
383
+ # Create a temporary directory and copy the gba file into it
384
+ # this is necessary to prevent mgba from overwriting the save file (and to prevent crashes)
385
+ tmp_dir = Path(tempfile.mkdtemp())
386
+ tmp_gba = tmp_dir / "rom.gba"
387
+ tmp_gba.write_bytes(Path(self.rom_path).read_bytes())
388
+
389
+ # Load the core
390
+ self.core = mgba.core.load_path(str(tmp_gba))
391
+ if self.core is None:
392
+ raise ValueError(f"Failed to load GBA file: {self.rom_path}")
393
+
394
+ # Auto-load save if it exists
395
+ self.core.autoload_save()
396
+ self.core.reset()
397
+
398
+ # Get dimensions from the core
399
+ self.width, self.height = self.core.desired_video_dimensions()
400
+ logger.info(f"mgba initialized with ROM: {self.rom_path} and dimensions: {self.width}x{self.height}")
401
+
402
+ # Set up video buffer for frame capture using mgba.image.Image
403
+ self.video_buffer = mgba.image.Image(self.width, self.height)
404
+ self.core.set_video_buffer(self.video_buffer)
405
+ self.core.reset() # Reset after setting video buffer
406
+
407
+ # Initialize memory reader
408
+ self.memory_reader = PokemonEmeraldReader(self.core)
409
+
410
+ # Set up callback for memory reader to invalidate emulator cache on area transitions
411
+ def invalidate_emulator_cache():
412
+ if hasattr(self, '_cached_state'):
413
+ delattr(self, '_cached_state')
414
+ if hasattr(self, '_cached_state_time'):
415
+ delattr(self, '_cached_state_time')
416
+
417
+ self.memory_reader._emulator_cache_invalidator = invalidate_emulator_cache
418
+
419
+ # Set up frame callback to invalidate memory cache
420
+ self.core.add_frame_callback(self._invalidate_mem_cache)
421
+
422
+ logger.info(f"mgba initialized with ROM: {self.rom_path}")
423
+ except Exception as e:
424
+ raise RuntimeError(f"Failed to initialize mgba: {e}")
425
+
426
+ def _invalidate_mem_cache(self):
427
+ """Invalidate memory cache when frame changes"""
428
+ self._mem_cache = {}
429
+
430
+ def _get_memory_region(self, region_id: int):
431
+ """Get memory region for efficient reading"""
432
+ if region_id not in self._mem_cache:
433
+ mem_core = self.core.memory.u8._core
434
+ size = ffi.new("size_t *")
435
+ ptr = ffi.cast("uint8_t *", mem_core.getMemoryBlock(mem_core, region_id, size))
436
+ self._mem_cache[region_id] = ffi.buffer(ptr, size[0])[:]
437
+ return self._mem_cache[region_id]
438
+
439
+ def read_memory(self, address: int, size: int = 1):
440
+ """Read memory at given address"""
441
+ region_id = address >> lib.BASE_OFFSET
442
+ mem_region = self._get_memory_region(region_id)
443
+ mask = len(mem_region) - 1
444
+ address &= mask
445
+ return mem_region[address:address + size]
446
+
447
+ def read_u8(self, address: int):
448
+ """Read unsigned 8-bit value"""
449
+ return int.from_bytes(self.read_memory(address, 1), byteorder='little', signed=False)
450
+
451
+ def read_u16(self, address: int):
452
+ """Read unsigned 16-bit value"""
453
+ return int.from_bytes(self.read_memory(address, 2), byteorder='little', signed=False)
454
+
455
+ def read_u32(self, address: int):
456
+ """Read unsigned 32-bit value"""
457
+ return int.from_bytes(self.read_memory(address, 4), byteorder='little', signed=False)
458
+
459
+ def tick(self, frames: int = 1):
460
+ """Advance emulator by given number of frames"""
461
+ if self.core:
462
+ for _ in range(frames):
463
+ self.core.run_frame()
464
+
465
+ def get_current_fps(self, base_fps: int = 30) -> int:
466
+ """Get current FPS - quadruples during dialog for faster text progression"""
467
+ # Use cached dialog state for performance
468
+ return base_fps * 4 if self._cached_dialog_state else base_fps
469
+
470
+ def _update_dialog_state_cache(self):
471
+ """Update cached dialog state (called periodically for performance)"""
472
+ import time
473
+ current_time = time.time()
474
+
475
+ # Only check dialog state periodically to avoid performance issues
476
+ if current_time - self._last_dialog_check_time >= self._dialog_check_interval:
477
+ if self.memory_reader:
478
+ new_dialog_state = self.memory_reader.is_in_dialog()
479
+ if new_dialog_state != self._cached_dialog_state:
480
+ self._cached_dialog_state = new_dialog_state
481
+ if new_dialog_state:
482
+ logger.debug("🎯 Dialog detected - switching to 4x FPS")
483
+ else:
484
+ logger.debug("✅ Dialog ended - reverting to normal FPS")
485
+ self._last_dialog_check_time = current_time
486
+
487
+ def press_key(self, key: str, frames: int = 2):
488
+ """Press a key for specified number of frames"""
489
+ if key not in self.KEY_MAP:
490
+ raise ValueError(f"Invalid key: {key}")
491
+ if frames < 2:
492
+ raise ValueError("Cannot press a key for less than 2 frames.")
493
+
494
+ key_code = self.KEY_MAP[key]
495
+ self.core.add_keys(key_code)
496
+ self.tick(frames - 1)
497
+ self.core.clear_keys(key_code)
498
+ self.tick(1)
499
+
500
+ def press_buttons(self, buttons: List[str], hold_frames: int = 10, release_frames: int = 10):
501
+ """Press a sequence of buttons"""
502
+ if not self.core:
503
+ return "Emulator not initialized"
504
+
505
+ for button in buttons:
506
+ if button.lower() not in self.KEY_MAP:
507
+ logger.warning(f"Unknown button: {button}")
508
+ continue
509
+
510
+ self.press_key(button.lower(), hold_frames)
511
+
512
+ self.tick(release_frames)
513
+ return f"Pressed: {'+'.join(buttons)}"
514
+
515
+ def run_frame_with_buttons(self, buttons: List[str]):
516
+ """Set buttons and advance one frame."""
517
+ if not self.core:
518
+ return
519
+
520
+ # Set all buttons for one frame
521
+ for button in buttons:
522
+ if button.lower() in self.KEY_MAP:
523
+ key_code = self.KEY_MAP[button.lower()]
524
+ self.core.add_keys(key_code)
525
+
526
+ self.core.run_frame()
527
+
528
+ # Clear all buttons
529
+ for button in buttons:
530
+ if button.lower() in self.KEY_MAP:
531
+ key_code = self.KEY_MAP[button.lower()]
532
+ self.core.clear_keys(key_code)
533
+
534
+ # Update dialog state cache for FPS adjustment
535
+ self._update_dialog_state_cache()
536
+
537
+ # Clear dialogue cache if A button was pressed (dismisses dialogue)
538
+ if buttons and any(button.lower() == 'a' for button in buttons):
539
+ if self.memory_reader:
540
+ self.memory_reader.clear_dialogue_cache_on_button_press()
541
+
542
+ # Clear state cache after action to ensure fresh data
543
+ if hasattr(self, '_cached_state'):
544
+ delattr(self, '_cached_state')
545
+ if hasattr(self, '_cached_state_time'):
546
+ delattr(self, '_cached_state_time')
547
+
548
+ def get_screenshot(self) -> Optional[Image.Image]:
549
+ """Return the current frame as a PIL image"""
550
+ if not self.core or not self.video_buffer:
551
+ return None
552
+
553
+ try:
554
+ # Use the built-in to_pil() method from mgba.image.Image
555
+ if hasattr(self.video_buffer, 'to_pil'):
556
+ screenshot = self.video_buffer.to_pil()
557
+ if screenshot:
558
+ screenshot = screenshot.convert("RGB")
559
+ return screenshot
560
+ else:
561
+ logger.warning("mgba.image.Image does not have to_pil method")
562
+ return None
563
+ else:
564
+ logger.warning("mgba.image.Image does not have to_pil method")
565
+ return None
566
+ except Exception as e:
567
+ logger.error(f"Failed to get screenshot: {e}")
568
+ return None
569
+
570
+ def save_state(self, path: Optional[str] = None) -> Optional[bytes]:
571
+ """Save current emulator state to file or return as bytes"""
572
+ if not self.core:
573
+ return None
574
+
575
+ try:
576
+ # Get the raw state data
577
+ raw_data = self.core.save_raw_state()
578
+
579
+ # Convert CFFI object to bytes if needed
580
+ if hasattr(raw_data, 'buffer'):
581
+ data = bytes(raw_data.buffer)
582
+ elif hasattr(raw_data, '__len__'):
583
+ data = bytes(raw_data)
584
+ else:
585
+ data = raw_data
586
+
587
+ if path:
588
+ with open(path, 'wb') as f:
589
+ f.write(data)
590
+ logger.info(f"State saved to {path}")
591
+
592
+ # Save corresponding milestones for this state
593
+ milestone_filename = self.milestone_tracker.save_milestones_for_state(path)
594
+ logger.info(f"Milestones saved to {milestone_filename}")
595
+
596
+ # Save the persistent location grids (contains all map data)
597
+ self._save_persistent_grids_for_state(path)
598
+
599
+ return data
600
+ except Exception as e:
601
+ logger.error(f"Failed to save state: {e}")
602
+ return None
603
+
604
+ def load_state(self, path: Optional[str] = None, state_bytes: Optional[bytes] = None):
605
+ """Load emulator state from file or memory"""
606
+ if not self.core:
607
+ return
608
+
609
+ try:
610
+ if path:
611
+ with open(path, 'rb') as f:
612
+ state_bytes = f.read()
613
+ if state_bytes:
614
+ # Ensure state_bytes is actually bytes
615
+ if not isinstance(state_bytes, bytes):
616
+ state_bytes = bytes(state_bytes)
617
+ self.core.load_raw_state(state_bytes)
618
+ logger.info("State loaded.")
619
+
620
+ # Reset dialog tracking and invalidate map cache when loading new state
621
+ if self.memory_reader:
622
+ self.memory_reader.reset_dialog_tracking()
623
+ # Don't clear buffer address on state load to avoid expensive rescans
624
+ self.memory_reader.invalidate_map_cache(clear_buffer_address=False)
625
+
626
+ # Persistent location maps will be loaded from the state file later
627
+
628
+ # Run a frame to ensure memory is properly loaded
629
+ self.core.run_frame()
630
+
631
+ # Only find map buffer addresses if we don't have them cached
632
+ # This avoids expensive memory scanning on every state load
633
+ if not self.memory_reader._map_buffer_addr:
634
+ if not self.memory_reader._find_map_buffer_addresses():
635
+ logger.warning("Could not find map buffer addresses after state load")
636
+ else:
637
+ logger.info(f"Map buffer found at 0x{self.memory_reader._map_buffer_addr:08X}")
638
+ else:
639
+ logger.debug(f"Using cached map buffer at 0x{self.memory_reader._map_buffer_addr:08X}")
640
+
641
+ # Set the current state file for both emulator and memory reader
642
+ self._current_state_file = path
643
+ if self.memory_reader:
644
+ self.memory_reader._current_state_file = path
645
+
646
+ # Load corresponding milestones for this state
647
+ if path:
648
+ # print( Loading state from path: {path}")
649
+ # Copy state files to cache first
650
+ self._copy_state_files_to_cache(path)
651
+ # Load milestones from cache file
652
+ cache_milestones_file = os.path.join(self.cache_dir, "milestones_progress.json")
653
+ if os.path.exists(cache_milestones_file):
654
+ # Update filename and then load
655
+ self.milestone_tracker.filename = cache_milestones_file
656
+ self.milestone_tracker.load_from_file()
657
+ logger.info(f"Milestones loaded from cache file: {cache_milestones_file}")
658
+ else:
659
+ # Fallback to state-specific file
660
+ self.milestone_tracker.load_milestones_for_state(path)
661
+ logger.info(f"Milestones loaded for state {path}")
662
+
663
+ # Load the persistent location grids (contains all map data)
664
+ # print( About to call _load_persistent_grids_for_state")
665
+ self._load_persistent_grids_for_state(path)
666
+ # print( Completed _load_persistent_grids_for_state")
667
+ except Exception as e:
668
+ logger.error(f"Failed to load state: {e}")
669
+
670
+ def _save_persistent_grids_for_state(self, state_filename: str):
671
+ """Save persistent location grids for a specific state file"""
672
+ try:
673
+ # Get the directory and base name of the state file
674
+ state_dir = os.path.dirname(state_filename)
675
+ base_name = os.path.splitext(os.path.basename(state_filename))[0]
676
+
677
+ # Only save grids for non-manual saves (splits, checkpoints, etc.)
678
+ # For manual saves, we only need the map_stitcher.json
679
+ if not base_name.startswith("manual_save"):
680
+ grids_filename = os.path.join(state_dir, f"{base_name}_grids.json")
681
+ # Save the persistent grids
682
+ save_persistent_world_map(grids_filename)
683
+ logger.info(f"Persistent grids saved to {grids_filename}")
684
+
685
+ # Always update and save MapStitcher data
686
+ if hasattr(self, 'memory_reader') and self.memory_reader:
687
+ # For manual saves, copy the current map_stitcher.json
688
+ if base_name.startswith("manual_save"):
689
+ # Copy the current map_stitcher_data.json from cache to manual_save_map_stitcher.json
690
+ cache_dir = ".pokeagent_cache"
691
+ current_stitcher_file = os.path.join(cache_dir, "map_stitcher_data.json")
692
+
693
+ # Also check for the old location in case it exists
694
+ if not os.path.exists(current_stitcher_file) and os.path.exists("map_stitcher_data.json"):
695
+ current_stitcher_file = "map_stitcher_data.json"
696
+
697
+ target_stitcher_file = os.path.join(state_dir, f"{base_name}_map_stitcher.json")
698
+
699
+ if os.path.exists(current_stitcher_file):
700
+ shutil.copy2(current_stitcher_file, target_stitcher_file)
701
+ logger.info(f"Map stitcher data copied to {target_stitcher_file}")
702
+
703
+ # Also save current milestones
704
+ if hasattr(self, 'milestone_tracker'):
705
+ milestone_filename = self.milestone_tracker.save_milestones_for_state(state_filename)
706
+ logger.info(f"Milestones saved to {milestone_filename}")
707
+ else:
708
+ # For regular saves, update the map stitcher save file path
709
+ self.memory_reader.update_map_stitcher_save_file(state_filename)
710
+ # Force save the map stitcher data
711
+ if self.memory_reader._map_stitcher:
712
+ self.memory_reader._map_stitcher.save_to_file()
713
+
714
+ except Exception as e:
715
+ logger.error(f"Error saving persistent grids for state: {e}")
716
+
717
+ def _load_persistent_grids_for_state(self, state_filename: str):
718
+ """Load persistent location grids for a specific state file"""
719
+ try:
720
+ # print( _load_persistent_grids_for_state called with: {state_filename}")
721
+ # Get the directory and base name of the state file
722
+ state_dir = os.path.dirname(state_filename)
723
+ base_name = os.path.splitext(os.path.basename(state_filename))[0]
724
+ grids_filename = os.path.join(state_dir, f"{base_name}_grids.json")
725
+
726
+ # Load persistent grids if they exist
727
+ if os.path.exists(grids_filename):
728
+ # Load the persistent grids
729
+ load_persistent_world_map(grids_filename)
730
+ logger.info(f"Persistent grids loaded from {grids_filename}")
731
+ else:
732
+ logger.info(f"No persistent grids file found for state: {grids_filename}")
733
+
734
+ # # Initialize MapStitcher with cache file
735
+ # if hasattr(self, 'memory_reader') and self.memory_reader:
736
+ # # print( About to initialize MapStitcher for state: {state_filename}")
737
+ # # Use cache file instead of state-specific file
738
+ # self.memory_reader.update_map_stitcher_save_file(state_filename, is_cache_file=True)
739
+ # # print( MapStitcher initialization completed for state: {state_filename}")
740
+ # else:
741
+ # # print( No memory_reader available, cannot initialize MapStitcher")
742
+
743
+ except Exception as e:
744
+ logger.error(f"Error loading persistent grids for state: {e}")
745
+
746
+ def _copy_state_files_to_cache(self, state_filename: str):
747
+ """Copy state-specific map stitcher and milestones to cache for working storage"""
748
+ import os
749
+ import shutil
750
+
751
+ # Ensure cache directory exists
752
+ cache_dir = ".pokeagent_cache"
753
+ os.makedirs(cache_dir, exist_ok=True)
754
+
755
+ # Copy map stitcher file to cache
756
+ state_dir = os.path.dirname(state_filename)
757
+ base_name = os.path.splitext(os.path.basename(state_filename))[0]
758
+ state_map_stitcher_file = os.path.join(state_dir, f"{base_name}_map_stitcher.json")
759
+ cache_map_stitcher_file = os.path.join(cache_dir, "map_stitcher_data.json")
760
+
761
+ if os.path.exists(state_map_stitcher_file):
762
+ # Check if the file has content
763
+ if os.path.getsize(state_map_stitcher_file) > 0:
764
+ shutil.copy2(state_map_stitcher_file, cache_map_stitcher_file)
765
+ # print( Copied map stitcher from {state_map_stitcher_file} to {cache_map_stitcher_file}")
766
+ else:
767
+ # Create a valid empty JSON structure for fresh start
768
+ import json
769
+ empty_data = {"map_areas": {}, "location_connections": {}}
770
+ with open(cache_map_stitcher_file, 'w') as f:
771
+ json.dump(empty_data, f, indent=2)
772
+ # print( State file empty, created fresh map stitcher cache")
773
+ else:
774
+ # Create a valid empty JSON structure for fresh start
775
+ import json
776
+ empty_data = {"map_areas": {}, "location_connections": {}}
777
+ with open(cache_map_stitcher_file, 'w') as f:
778
+ json.dump(empty_data, f, indent=2)
779
+ # print( No state file found, created fresh map stitcher cache")
780
+
781
+ # Copy milestones file to main directory (not cache, as requested)
782
+ state_milestones_file = os.path.join(state_dir, f"{base_name}_milestones.json")
783
+ cache_milestones_file = os.path.join(self.cache_dir, "milestones_progress.json") # Cache directory as requested
784
+
785
+ if os.path.exists(state_milestones_file):
786
+ shutil.copy2(state_milestones_file, cache_milestones_file)
787
+ # # print( Copied milestones from {state_milestones_file} to {cache_milestones_file}")
788
+ # else:
789
+ # # print( No state-specific milestones file found: {state_milestones_file}")
790
+
791
+ def start_frame_capture(self, fps: int = 30):
792
+ """Start asynchronous frame capture"""
793
+ self.running = True
794
+ self.frame_thread = threading.Thread(target=self._frame_loop, args=(fps,), daemon=True)
795
+ self.frame_thread.start()
796
+
797
+ def _frame_loop(self, fps: int):
798
+ interval = 1.0 / fps
799
+ while self.running:
800
+ start = time.time()
801
+ frame = self.get_screenshot()
802
+ if frame:
803
+ np_frame = np.array(frame)
804
+ if self.frame_queue.full():
805
+ self.frame_queue.get_nowait()
806
+ self.frame_queue.put(np_frame)
807
+ self.current_frame = np_frame
808
+ elapsed = time.time() - start
809
+ time.sleep(max(0.001, interval - elapsed))
810
+
811
+ def get_latest_frame(self) -> Optional[np.ndarray]:
812
+ """Return last captured frame"""
813
+ return self.current_frame.copy() if self.current_frame is not None else None
814
+
815
+ def process_input(self, input_data: Dict[str, Any]) -> str:
816
+ """Handle JSON-style input payload"""
817
+ try:
818
+ input_type = input_data.get('type', 'button')
819
+ if input_type == 'button':
820
+ button = input_data.get('button')
821
+ if button:
822
+ return self.press_buttons([button])
823
+ elif input_type == 'sequence':
824
+ buttons = input_data.get('buttons', [])
825
+ return self.press_buttons(buttons)
826
+ elif input_type == 'hold':
827
+ button = input_data.get('button')
828
+ duration = int(input_data.get('duration', 1.0) * 60)
829
+ return self.press_buttons([button], hold_frames=duration)
830
+ return "Invalid input type"
831
+ except Exception as e:
832
+ logger.error(f"Input error: {e}")
833
+ return str(e)
834
+
835
+ def stop(self):
836
+ """Stop emulator and cleanup"""
837
+ self.running = False
838
+ if self.frame_thread and self.frame_thread.is_alive():
839
+ self.frame_thread.join(timeout=1)
840
+ if self.core:
841
+ self.core = None
842
+ logger.info("Emulator stopped.")
843
+
844
+ def get_info(self) -> Dict[str, Any]:
845
+ """Return metadata about emulator state"""
846
+ return {
847
+ "rom_path": self.rom_path,
848
+ "dimensions": (self.width, self.height),
849
+ "initialized": self.core is not None,
850
+ "headless": self.headless,
851
+ "sound": self.sound,
852
+ }
853
+
854
+ def get_comprehensive_state(self, screenshot=None) -> Dict[str, Any]:
855
+ """Get comprehensive game state including visual and memory data using enhanced memory reader
856
+
857
+ Args:
858
+ screenshot: Optional PIL Image screenshot to use. If None, will call get_screenshot()
859
+ """
860
+ # Simple caching to avoid redundant calls within a short time window
861
+ import time
862
+ current_time = time.time()
863
+
864
+ # Cache state for 100ms to avoid excessive memory reads
865
+ if hasattr(self, '_cached_state') and hasattr(self, '_cached_state_time'):
866
+ if current_time - self._cached_state_time < 0.1: # 100ms cache
867
+ return self._cached_state
868
+
869
+ # Use provided screenshot or get a new one
870
+ if screenshot is None:
871
+ screenshot = self.get_screenshot()
872
+
873
+ # Use the enhanced memory reader's comprehensive state method
874
+ if self.memory_reader:
875
+ state = self.memory_reader.get_comprehensive_state(screenshot)
876
+ else:
877
+ # Fallback to basic state
878
+ state = {
879
+ "visual": {
880
+ "screenshot": None,
881
+ "resolution": [self.width, self.height]
882
+ },
883
+ "player": {
884
+ "position": None,
885
+ "location": None,
886
+ "name": None
887
+ },
888
+ "game": {
889
+ "money": None,
890
+ "party": None,
891
+ "game_state": None,
892
+ "is_in_battle": None,
893
+ "time": None,
894
+ "badges": None,
895
+ "items": None,
896
+ "item_count": None,
897
+ "pokedex_caught": None,
898
+ "pokedex_seen": None
899
+ },
900
+ "map": {
901
+ "tiles": None,
902
+ "tile_names": None,
903
+ "metatile_behaviors": None,
904
+ "metatile_info": None,
905
+ "traversability": None
906
+ }
907
+ }
908
+
909
+ # Use screenshot already captured
910
+ if screenshot is not None and hasattr(screenshot, 'save'):
911
+ state["visual"]["screenshot"] = screenshot
912
+
913
+ # Cache the result
914
+ self._cached_state = state
915
+ self._cached_state_time = current_time
916
+
917
+ return state
918
+
919
+ def _get_tile_passability(self, tile_data) -> bool:
920
+ """Determine if a tile is passable based on collision bits (like GeminiPlaysPokemonLive)"""
921
+ if not tile_data or len(tile_data) < 3:
922
+ return True # Default to passable if no data
923
+
924
+ # tile_data is (metatile_id, behavior, collision, elevation)
925
+ collision = tile_data[2] if len(tile_data) > 2 else 0
926
+
927
+ # Primary rule: collision == 0 means passable, non-zero means blocked
928
+ return collision == 0
929
+
930
+ def _get_tile_encounter_possible(self, tile_data) -> bool:
931
+ """Determine if a tile can trigger encounters based on its behavior"""
932
+ if not tile_data or len(tile_data) < 2:
933
+ return False
934
+
935
+ # Import here to avoid circular imports
936
+ from .enums import MetatileBehavior
937
+
938
+ behavior = tile_data[1] if len(tile_data) > 1 else None
939
+ if not behavior:
940
+ return False
941
+
942
+ # Check for encounter tiles
943
+ encounter_behaviors = {
944
+ MetatileBehavior.TALL_GRASS,
945
+ MetatileBehavior.LONG_GRASS,
946
+ MetatileBehavior.UNUSED_05,
947
+ MetatileBehavior.DEEP_SAND,
948
+ MetatileBehavior.CAVE,
949
+ MetatileBehavior.INDOOR_ENCOUNTER,
950
+ MetatileBehavior.POND_WATER,
951
+ MetatileBehavior.INTERIOR_DEEP_WATER,
952
+ MetatileBehavior.DEEP_WATER,
953
+ MetatileBehavior.OCEAN_WATER,
954
+ MetatileBehavior.SEAWEED,
955
+ MetatileBehavior.ASHGRASS,
956
+ MetatileBehavior.FOOTPRINTS,
957
+ MetatileBehavior.SEAWEED_NO_SURFACING
958
+ }
959
+
960
+ return behavior in encounter_behaviors
961
+
962
+ def _get_tile_surfable(self, tile_data) -> bool:
963
+ """Determine if a tile can be surfed on based on its behavior"""
964
+ if not tile_data or len(tile_data) < 2:
965
+ return False
966
+
967
+ # Import here to avoid circular imports
968
+ from .enums import MetatileBehavior
969
+
970
+ behavior = tile_data[1] if len(tile_data) > 1 else None
971
+ if not behavior:
972
+ return False
973
+
974
+ # Check for surfable tiles
975
+ surfable_behaviors = {
976
+ MetatileBehavior.POND_WATER,
977
+ MetatileBehavior.INTERIOR_DEEP_WATER,
978
+ MetatileBehavior.DEEP_WATER,
979
+ MetatileBehavior.SOOTOPOLIS_DEEP_WATER,
980
+ MetatileBehavior.OCEAN_WATER,
981
+ MetatileBehavior.NO_SURFACING,
982
+ MetatileBehavior.SEAWEED,
983
+ MetatileBehavior.SEAWEED_NO_SURFACING
984
+ }
985
+
986
+ return behavior in surfable_behaviors
987
+
988
+ def get_player_position(self) -> Optional[Dict[str, int]]:
989
+ """Get current player position"""
990
+ if self.memory_reader:
991
+ try:
992
+ coords = self.memory_reader.read_coordinates()
993
+ if coords:
994
+ return {"x": coords[0], "y": coords[1]}
995
+ except Exception as e:
996
+ logger.warning(f"Failed to read player position: {e}")
997
+ return None
998
+
999
+ def get_map_location(self) -> Optional[str]:
1000
+ """Get current map location name"""
1001
+ if self.memory_reader:
1002
+ try:
1003
+ return self.memory_reader.read_location()
1004
+ except Exception as e:
1005
+ logger.warning(f"Failed to read map location: {e}")
1006
+ return None
1007
+
1008
+ def get_money(self) -> Optional[int]:
1009
+ """Get current money amount"""
1010
+ if self.memory_reader:
1011
+ try:
1012
+ return self.memory_reader.read_money()
1013
+ except Exception as e:
1014
+ logger.warning(f"Failed to read money: {e}")
1015
+ return None
1016
+
1017
+ def get_party_pokemon(self) -> Optional[List[Dict[str, Any]]]:
1018
+ """Get current party Pokemon"""
1019
+ if self.memory_reader:
1020
+ try:
1021
+ party = self.memory_reader.read_party_pokemon()
1022
+ if party:
1023
+ return [
1024
+ {
1025
+ "species": pokemon.species_name,
1026
+ "level": pokemon.level,
1027
+ "current_hp": pokemon.current_hp,
1028
+ "max_hp": pokemon.max_hp,
1029
+ "status": pokemon.status.get_status_name() if pokemon.status else "OK",
1030
+ "types": [t for t in [pokemon.type1.name if pokemon.type1 else None,
1031
+ pokemon.type2.name if pokemon.type2 else None] if t is not None]
1032
+ }
1033
+ for pokemon in party
1034
+ ]
1035
+ except Exception as e:
1036
+ logger.warning(f"Failed to read party Pokemon: {e}")
1037
+ return None
1038
+
1039
+ def get_map_tiles(self, radius: int = 7) -> Optional[List[List[tuple]]]:
1040
+ """Get map tiles around player"""
1041
+ if self.memory_reader:
1042
+ try:
1043
+ return self.memory_reader.read_map_around_player(radius=radius)
1044
+ except Exception as e:
1045
+ logger.warning(f"Failed to read map tiles: {e}")
1046
+ return None
1047
+
1048
+ def test_memory_reading(self) -> Dict[str, Any]:
1049
+ """Test memory reading capabilities and return diagnostic information"""
1050
+ if not self.memory_reader:
1051
+ return {"error": "Memory reader not initialized"}
1052
+
1053
+ try:
1054
+ # Get memory diagnostics
1055
+ diagnostics = self.memory_reader.test_memory_access()
1056
+
1057
+ # Test some basic reads
1058
+ test_results = {
1059
+ "player_name": None,
1060
+ "money": None,
1061
+ "coordinates": None,
1062
+ "party_size": None,
1063
+ "location": None
1064
+ }
1065
+
1066
+ try:
1067
+ test_results["player_name"] = self.memory_reader.read_player_name()
1068
+ except Exception as e:
1069
+ test_results["player_name_error"] = str(e)
1070
+
1071
+ try:
1072
+ test_results["money"] = self.memory_reader.read_money()
1073
+ except Exception as e:
1074
+ test_results["money_error"] = str(e)
1075
+
1076
+ try:
1077
+ test_results["coordinates"] = self.memory_reader.read_coordinates()
1078
+ except Exception as e:
1079
+ test_results["coordinates_error"] = str(e)
1080
+
1081
+ try:
1082
+ test_results["party_size"] = self.memory_reader.read_party_size()
1083
+ except Exception as e:
1084
+ test_results["party_size_error"] = str(e)
1085
+
1086
+ try:
1087
+ test_results["location"] = self.memory_reader.read_location()
1088
+ except Exception as e:
1089
+ test_results["location_error"] = str(e)
1090
+
1091
+ return {
1092
+ "diagnostics": diagnostics,
1093
+ "test_results": test_results
1094
+ }
1095
+ except Exception as e:
1096
+ return {"error": f"Failed to run memory tests: {e}"}
1097
+
1098
+ def check_and_update_milestones(self, game_state: Dict[str, Any]):
1099
+ """Check current game state and update milestones"""
1100
+ try:
1101
+ # Debug: Show current state
1102
+ location = game_state.get("player", {}).get("location", "Unknown")
1103
+ # print(f"🔍 Checking milestones for location: {location}")
1104
+ # Only check milestones that aren't already completed
1105
+ milestones_to_check = [
1106
+ # Phase 1: Game Initialization
1107
+ "GAME_RUNNING", "PLAYER_NAME_SET", "INTRO_CUTSCENE_COMPLETE",
1108
+
1109
+ # Phase 2: Tutorial & Starting Town
1110
+ "LITTLEROOT_TOWN", "PLAYER_HOUSE_ENTERED", "PLAYER_BEDROOM",
1111
+ "RIVAL_HOUSE", "RIVAL_BEDROOM",
1112
+
1113
+ # Phase 3: Professor Birch & Starter
1114
+ "ROUTE_101", "STARTER_CHOSEN", "BIRCH_LAB_VISITED",
1115
+
1116
+ # Phase 4: Rival
1117
+ "OLDALE_TOWN", "ROUTE_103", "RECEIVED_POKEDEX",
1118
+
1119
+ # Phase 5: Route 102 & Petalburg
1120
+ "ROUTE_102", "PETALBURG_CITY", "DAD_FIRST_MEETING", "GYM_EXPLANATION",
1121
+
1122
+ # Phase 6: Road to Rustboro City
1123
+ "ROUTE_104_SOUTH", "PETALBURG_WOODS", "TEAM_AQUA_GRUNT_DEFEATED",
1124
+ "ROUTE_104_NORTH", "RUSTBORO_CITY",
1125
+
1126
+ # Phase 7: First Gym Challenge
1127
+ "RUSTBORO_GYM_ENTERED", "ROXANNE_DEFEATED", "FIRST_GYM_COMPLETE"
1128
+ ]
1129
+
1130
+ for milestone_id in milestones_to_check:
1131
+ if not self.milestone_tracker.is_completed(milestone_id):
1132
+ if self._check_milestone_condition(milestone_id, game_state):
1133
+ print(f"🎯 Milestone detected: {milestone_id}")
1134
+ self.milestone_tracker.mark_completed(milestone_id)
1135
+ except Exception as e:
1136
+ logger.warning(f"Error checking milestones: {e}")
1137
+
1138
+ def _check_milestone_condition(self, milestone_id: str, game_state: Dict[str, Any]) -> bool:
1139
+ """Check if a specific milestone condition is met based on current game state"""
1140
+ try:
1141
+ # Test milestones (should always work)
1142
+ if milestone_id == "GAME_RUNNING":
1143
+ return True # If we can execute this, game is running
1144
+ elif milestone_id == "HAS_PARTY":
1145
+ if game_state:
1146
+ party = game_state.get("player", {}).get("party", [])
1147
+ return len(party) > 0
1148
+ return False
1149
+
1150
+ # Location-based milestones - check current location
1151
+ elif milestone_id == "LITTLEROOT_TOWN":
1152
+ if game_state:
1153
+ location = game_state.get("player", {}).get("location", "")
1154
+ return "LITTLEROOT" in str(location).upper()
1155
+ return False
1156
+
1157
+ elif milestone_id == "OLDALE_TOWN":
1158
+ if game_state:
1159
+ # Only count Oldale Town if we've already been to Littleroot Town
1160
+ if not self.milestone_tracker.is_completed("LITTLEROOT_TOWN"):
1161
+ return False
1162
+ location = game_state.get("player", {}).get("location", "")
1163
+ return "OLDALE" in str(location).upper()
1164
+ return False
1165
+ elif milestone_id == "RUSTBORO_CITY":
1166
+ if game_state:
1167
+ # Only count Rustboro City if we've already been to Petalburg City
1168
+ if not self.milestone_tracker.is_completed("PETALBURG_CITY"):
1169
+ return False
1170
+ location = game_state.get("player", {}).get("location", "")
1171
+ return "RUSTBORO" in str(location).upper()
1172
+ return False
1173
+ elif milestone_id == "DEWFORD_TOWN":
1174
+ if game_state:
1175
+ # Only count Dewford Town if we've already been to Rustboro City
1176
+ if not self.milestone_tracker.is_completed("RUSTBORO_CITY"):
1177
+ return False
1178
+ location = game_state.get("player", {}).get("location", "")
1179
+ return "DEWFORD" in str(location).upper()
1180
+ return False
1181
+ elif milestone_id == "SLATEPORT_CITY":
1182
+ if game_state:
1183
+ # Only count Slateport City if we've already been to Dewford Town
1184
+ if not self.milestone_tracker.is_completed("DEWFORD_TOWN"):
1185
+ return False
1186
+ location = game_state.get("player", {}).get("location", "")
1187
+ return "SLATEPORT" in str(location).upper()
1188
+ return False
1189
+ elif milestone_id == "MAUVILLE_CITY":
1190
+ if game_state:
1191
+ # Only count Mauville City if we've already been to Slateport City
1192
+ if not self.milestone_tracker.is_completed("SLATEPORT_CITY"):
1193
+ return False
1194
+ location = game_state.get("player", {}).get("location", "")
1195
+ return "MAUVILLE" in str(location).upper()
1196
+ return False
1197
+
1198
+
1199
+
1200
+ # Badge milestones - check badge count/list
1201
+ elif milestone_id == "STONE_BADGE":
1202
+ if game_state:
1203
+ badges = game_state.get("game", {}).get("badges", [])
1204
+ if isinstance(badges, list):
1205
+ return len(badges) >= 1 or any("Stone" in str(b) for b in badges)
1206
+ elif isinstance(badges, int):
1207
+ return badges >= 1
1208
+ return False
1209
+ elif milestone_id == "KNUCKLE_BADGE":
1210
+ if game_state:
1211
+ badges = game_state.get("game", {}).get("badges", [])
1212
+ if isinstance(badges, list):
1213
+ return len(badges) >= 2 or any("Knuckle" in str(b) for b in badges)
1214
+ elif isinstance(badges, int):
1215
+ return badges >= 2
1216
+ return False
1217
+ elif milestone_id == "DYNAMO_BADGE":
1218
+ if game_state:
1219
+ badges = game_state.get("game", {}).get("badges", [])
1220
+ if isinstance(badges, list):
1221
+ return len(badges) >= 3 or any("Dynamo" in str(b) for b in badges)
1222
+ elif isinstance(badges, int):
1223
+ return badges >= 3
1224
+ return False
1225
+
1226
+ # Phase 1: Game Initialization milestones
1227
+ elif milestone_id == "INTRO_CUTSCENE_COMPLETE":
1228
+ if game_state:
1229
+ location = game_state.get("player", {}).get("location", "")
1230
+ return "MOVING_VAN" in str(location).upper()
1231
+ return False
1232
+ elif milestone_id == "PLAYER_NAME_SET":
1233
+ if game_state:
1234
+ player_name = game_state.get("player", {}).get("name", "")
1235
+ # Player name is set if we have a non-empty name that's not the default
1236
+ return (player_name and
1237
+ str(player_name).strip() != "" and
1238
+ str(player_name).strip() not in ["", "UNKNOWN", "PLAYER"])
1239
+ return False
1240
+
1241
+ # Phase 2: Tutorial & Starting Town milestones
1242
+ elif milestone_id == "PLAYER_HOUSE_ENTERED":
1243
+ if game_state:
1244
+ location = game_state.get("player", {}).get("location", "")
1245
+ return "LITTLEROOT TOWN BRENDANS HOUSE 1F" in str(location).upper()
1246
+ return False
1247
+ elif milestone_id == "PLAYER_BEDROOM":
1248
+ if game_state:
1249
+ location = game_state.get("player", {}).get("location", "")
1250
+ return "LITTLEROOT TOWN BRENDANS HOUSE 2F" in str(location).upper()
1251
+ return False
1252
+ elif milestone_id == "RIVAL_HOUSE":
1253
+ if game_state:
1254
+ location = game_state.get("player", {}).get("location", "")
1255
+ return "LITTLEROOT TOWN MAYS HOUSE 1F" in str(location).upper()
1256
+ return False
1257
+ elif milestone_id == "RIVAL_BEDROOM":
1258
+ if game_state:
1259
+ location = game_state.get("player", {}).get("location", "")
1260
+ return "LITTLEROOT TOWN MAYS HOUSE 2F" in str(location).upper()
1261
+ return False
1262
+
1263
+ # Phase 3: Professor Birch & Starter milestones
1264
+ elif milestone_id == "ROUTE_101":
1265
+ if game_state:
1266
+ location = game_state.get("player", {}).get("location", "")
1267
+ return "ROUTE_101" in str(location).upper() or "ROUTE 101" in str(location).upper()
1268
+ return False
1269
+ elif milestone_id == "STARTER_CHOSEN":
1270
+ if game_state:
1271
+ party = game_state.get("player", {}).get("party", [])
1272
+ return len(party) >= 1 and any(p.get("species_name", "").strip() for p in party)
1273
+ return False
1274
+ elif milestone_id == "BIRCH_LAB_VISITED":
1275
+ if game_state:
1276
+ location = game_state.get("player", {}).get("location", "")
1277
+ return "LITTLEROOT TOWN PROFESSOR BIRCHS LAB" in str(location).upper()
1278
+ return False
1279
+
1280
+ # Phase 4: Early Route Progression milestones
1281
+ elif milestone_id == "ROUTE_103":
1282
+ if game_state:
1283
+ # Only count Route 103 if we've already been to Route 101 and have starter
1284
+ if not self.milestone_tracker.is_completed("ROUTE_101"):
1285
+ return False
1286
+ if not self.milestone_tracker.is_completed("STARTER_CHOSEN"):
1287
+ return False
1288
+ location = game_state.get("player", {}).get("location", "")
1289
+ return "ROUTE_103" in str(location).upper() or "ROUTE 103" in str(location).upper()
1290
+ return False
1291
+ # elif milestone_id == "RIVAL_BATTLE_1":
1292
+ # # Check for specific state hash from dialog after the battle (c9086d56)
1293
+ # if game_state:
1294
+ # # Create state hash for comparison
1295
+ # state_str = str(game_state)
1296
+ # state_hash = hashlib.md5(state_str.encode()).hexdigest()[:8]
1297
+
1298
+ # # Check for battle completion state hash or traditional conditions
1299
+ # return (state_hash == "c9086d56" or
1300
+ # (self.milestone_tracker.is_completed("ROUTE_103") and
1301
+ # self.milestone_tracker.is_completed("STARTER_CHOSEN")))
1302
+ # return False
1303
+ elif milestone_id == "RECEIVED_POKEDEX":
1304
+ if game_state:
1305
+ # Check if we're in Birch's lab AND have completed Route 103
1306
+ location = game_state.get("player", {}).get("location", "")
1307
+ return (self.milestone_tracker.is_completed("ROUTE_103") and
1308
+ "LITTLEROOT TOWN PROFESSOR BIRCHS LAB" in str(location).upper())
1309
+ return False
1310
+ ## Phase 5: Route 102 & Petalburg
1311
+ elif milestone_id == "ROUTE_102":
1312
+ if game_state:
1313
+ # Only count Route 102 if we've received Pokedex
1314
+ if not self.milestone_tracker.is_completed("RECEIVED_POKEDEX"):
1315
+ return False
1316
+ location = game_state.get("player", {}).get("location", "")
1317
+ return "ROUTE_102" in str(location).upper() or "ROUTE 102" in str(location).upper()
1318
+ return False
1319
+ elif milestone_id == "PETALBURG_CITY":
1320
+ if game_state:
1321
+ # Enforce proper game progression through required towns
1322
+ if not self.milestone_tracker.is_completed("LITTLEROOT_TOWN"):
1323
+ return False
1324
+ if not self.milestone_tracker.is_completed("OLDALE_TOWN"):
1325
+ return False
1326
+ location = game_state.get("player", {}).get("location", "")
1327
+ return "PETALBURG" in str(location).upper()
1328
+ return False
1329
+ elif milestone_id == "DAD_FIRST_MEETING":
1330
+ # Meeting Dad happens in Petalburg Gym
1331
+ if game_state:
1332
+ # Must have visited Petalburg City first
1333
+ if not self.milestone_tracker.is_completed("PETALBURG_CITY"):
1334
+ return False
1335
+ location = game_state.get("player", {}).get("location", "")
1336
+ return "PETALBURG CITY GYM" in str(location).upper() or "PETALBURG_CITY_GYM" in str(location).upper()
1337
+ return False
1338
+ elif milestone_id == "GYM_EXPLANATION":
1339
+ # Gym explanation happens after meeting Dad in the gym
1340
+ if game_state:
1341
+ # Must have met Dad and still be in gym
1342
+ if not self.milestone_tracker.is_completed("DAD_FIRST_MEETING"):
1343
+ return False
1344
+ location = game_state.get("player", {}).get("location", "")
1345
+ return "PETALBURG CITY GYM" in str(location).upper() or "PETALBURG_CITY_GYM" in str(location).upper()
1346
+ return False
1347
+
1348
+ # Phase 6: Pre-Gym Preparation milestones
1349
+ elif milestone_id == "ROUTE_104_SOUTH":
1350
+ if game_state:
1351
+ # Only count if we've been to Petalburg
1352
+ if not self.milestone_tracker.is_completed("PETALBURG_CITY"):
1353
+ return False
1354
+ location = game_state.get("player", {}).get("location", "")
1355
+ return "ROUTE_104" in str(location).upper() or "ROUTE 104" in str(location).upper()
1356
+ return False
1357
+ elif milestone_id == "MR_BRINEY_MET":
1358
+ # Assume meeting Mr. Briney happens on Route 104
1359
+ if game_state:
1360
+ return self.milestone_tracker.is_completed("ROUTE_104_SOUTH")
1361
+ return False
1362
+ elif milestone_id == "PETALBURG_WOODS":
1363
+ if game_state:
1364
+ # Only count if we've been to Route 104
1365
+ if not self.milestone_tracker.is_completed("ROUTE_104_SOUTH"):
1366
+ return False
1367
+ location = game_state.get("player", {}).get("location", "")
1368
+ return "PETALBURG_WOODS" in str(location).upper() or "PETALBURG WOODS" in str(location).upper()
1369
+ return False
1370
+ elif milestone_id == "TEAM_AQUA_GRUNT_DEFEATED":
1371
+ # Team Aqua grunt defeated at specific location in Petalburg Woods
1372
+ if game_state:
1373
+ # Must have visited Petalburg Woods
1374
+ if not self.milestone_tracker.is_completed("PETALBURG_WOODS"):
1375
+ return False
1376
+ location = game_state.get("player", {}).get("location", "")
1377
+ # Check if in Petalburg Woods and at specific coordinates
1378
+ if "PETALBURG_WOODS" in str(location).upper() or "PETALBURG WOODS" in str(location).upper():
1379
+ # Check for Map_18_0B at coords (26,23) or (27,23)
1380
+ pos = game_state.get("player", {}).get("pos", [])
1381
+ if len(pos) >= 2:
1382
+ x, y = pos[0], pos[1]
1383
+ if y == 23 and x in [26, 27]:
1384
+ return True
1385
+ return self.milestone_tracker.is_completed("PETALBURG_WOODS")
1386
+ return False
1387
+ elif milestone_id == "DEVON_GOODS_OBTAINED":
1388
+ # Assume Devon Goods obtained after defeating Team Aqua grunt
1389
+ if game_state:
1390
+ return self.milestone_tracker.is_completed("TEAM_AQUA_GRUNT_DEFEATED")
1391
+ return False
1392
+
1393
+ # Phase 8: Rustboro City Approach milestones
1394
+ elif milestone_id == "ROUTE_104_NORTH":
1395
+ if game_state:
1396
+ # Only count if we've been through Petalburg Woods
1397
+ if not self.milestone_tracker.is_completed("PETALBURG_WOODS"):
1398
+ return False
1399
+ location = game_state.get("player", {}).get("location", "")
1400
+ return (("ROUTE_104" in str(location).upper() or "ROUTE 104" in str(location).upper()) and
1401
+ self.milestone_tracker.is_completed("DEVON_GOODS_OBTAINED"))
1402
+ return False
1403
+ elif milestone_id == "DEVON_CORP_VISITED":
1404
+ if game_state:
1405
+ location = game_state.get("player", {}).get("location", "")
1406
+ return ("DEVON" in str(location).upper() and
1407
+ self.milestone_tracker.is_completed("RUSTBORO_CITY"))
1408
+ return False
1409
+ elif milestone_id == "DEVON_GOODS_DELIVERED":
1410
+ # Assume goods delivered after visiting Devon Corp
1411
+ if game_state:
1412
+ return self.milestone_tracker.is_completed("DEVON_CORP_VISITED")
1413
+ return False
1414
+ elif milestone_id == "LETTER_RECEIVED":
1415
+ # Assume letter received after delivering goods
1416
+ if game_state:
1417
+ return self.milestone_tracker.is_completed("DEVON_GOODS_DELIVERED")
1418
+ return False
1419
+ elif milestone_id == "POKEBALLS_PURCHASED":
1420
+ # Assume Pokeballs purchased in Rustboro City
1421
+ if game_state:
1422
+ return self.milestone_tracker.is_completed("RUSTBORO_CITY")
1423
+ return False
1424
+
1425
+ # Phase 9: Gym Preparation milestones
1426
+ elif milestone_id == "RUSTBORO_GYM_ENTERED":
1427
+ if game_state:
1428
+ # Must have visited Rustboro City first
1429
+ if not self.milestone_tracker.is_completed("RUSTBORO_CITY"):
1430
+ return False
1431
+ location = game_state.get("player", {}).get("location", "")
1432
+ return "RUSTBORO_GYM" in str(location).upper() or "RUSTBORO CITY GYM" in str(location).upper()
1433
+ return False
1434
+ elif milestone_id == "GYM_TRAINERS_DEFEATED":
1435
+ # Assume gym trainers defeated after entering gym
1436
+ if game_state:
1437
+ return self.milestone_tracker.is_completed("RUSTBORO_GYM_ENTERED")
1438
+ return False
1439
+ elif milestone_id == "ROXANNE_BATTLE_STARTED":
1440
+ # Assume Roxanne battle started after defeating gym trainers
1441
+ if game_state:
1442
+ return self.milestone_tracker.is_completed("GYM_TRAINERS_DEFEATED")
1443
+ return False
1444
+
1445
+ # Phase 10: First Gym Victory milestones
1446
+ elif milestone_id == "ROXANNE_DEFEATED":
1447
+ # Roxanne defeated when Stone Badge is obtained
1448
+ if game_state:
1449
+ # Must have Stone Badge
1450
+ return self.milestone_tracker.is_completed("STONE_BADGE")
1451
+ return False
1452
+ elif milestone_id == "TM_ROCK_TOMB_RECEIVED":
1453
+ # Assume TM received after defeating Roxanne
1454
+ if game_state:
1455
+ return self.milestone_tracker.is_completed("ROXANNE_DEFEATED")
1456
+ return False
1457
+ elif milestone_id == "FIRST_GYM_COMPLETE":
1458
+ # Complete after getting Stone Badge and exiting gym
1459
+ if game_state:
1460
+ # Must have Stone Badge and not be in gym
1461
+ if not self.milestone_tracker.is_completed("STONE_BADGE"):
1462
+ return False
1463
+ location = game_state.get("player", {}).get("location", "")
1464
+ # Not in any gym
1465
+ return "GYM" not in str(location).upper()
1466
+ return False
1467
+
1468
+ return False
1469
+
1470
+ except Exception as e:
1471
+ logger.warning(f"Error checking milestone condition {milestone_id}: {e}")
1472
+ return False
1473
+
1474
+ def get_milestones(self) -> Dict[str, Any]:
1475
+ """Get current milestone data and progress"""
1476
+ try:
1477
+ # Get current game state and update milestones
1478
+ # Use cached state if available to avoid redundant calls
1479
+ game_state = self.get_comprehensive_state()
1480
+ # Only update milestones occasionally to avoid performance issues
1481
+ import time
1482
+ current_time = time.time()
1483
+ if not hasattr(self, '_last_milestone_update') or current_time - self._last_milestone_update > 1.0: # Update at most once per second
1484
+ self.check_and_update_milestones(game_state)
1485
+ self._last_milestone_update = current_time
1486
+
1487
+ # Use loaded milestones from the milestone tracker
1488
+ milestones = []
1489
+ for i, (milestone_id, milestone_data) in enumerate(self.milestone_tracker.milestones.items()):
1490
+ milestones.append({
1491
+ "id": i + 1,
1492
+ "name": milestone_data.get("name", milestone_id),
1493
+ "category": milestone_data.get("category", "unknown"),
1494
+ "completed": milestone_data.get("completed", False),
1495
+ "timestamp": milestone_data.get("timestamp", None)
1496
+ })
1497
+
1498
+ # Calculate summary stats
1499
+ completed_count = sum(1 for m in milestones if m["completed"])
1500
+ total_count = len(milestones)
1501
+
1502
+ # Handle location data properly
1503
+ location_data = game_state.get("player", {}).get("location", "")
1504
+ if isinstance(location_data, dict):
1505
+ current_location = location_data.get("map_name", "UNKNOWN")
1506
+ else:
1507
+ current_location = str(location_data) if location_data else "UNKNOWN"
1508
+
1509
+ # Handle badges data properly
1510
+ badges_data = game_state.get("game", {}).get("badges", 0)
1511
+ if isinstance(badges_data, list):
1512
+ badge_count = sum(1 for b in badges_data if b)
1513
+ else:
1514
+ badge_count = badges_data if isinstance(badges_data, int) else 0
1515
+
1516
+ return {
1517
+ "milestones": milestones,
1518
+ "completed": completed_count,
1519
+ "total": total_count,
1520
+ "progress": completed_count / total_count if total_count > 0 else 0,
1521
+ "current_location": current_location,
1522
+ "badges": badge_count,
1523
+ "pokedex_seen": game_state.get("game", {}).get("pokedex_seen", 0),
1524
+ "pokedex_caught": game_state.get("game", {}).get("pokedex_caught", 0),
1525
+ "party_size": len(game_state.get("player", {}).get("party", [])),
1526
+ "tracking_system": "file_based",
1527
+ "milestone_file": self.milestone_tracker.filename
1528
+ }
1529
+
1530
+ except Exception as e:
1531
+ logger.error(f"Error getting milestones: {e}")
1532
+ # Fallback to basic milestones if memory reading fails
1533
+ basic_milestones = [
1534
+ {"id": 1, "name": "GAME_STARTED", "category": "basic", "completed": True, "timestamp": time.time()},
1535
+ {"id": 2, "name": "EMULATOR_RUNNING", "category": "basic", "completed": True, "timestamp": time.time()},
1536
+ ]
1537
+ return {
1538
+ "milestones": basic_milestones,
1539
+ "completed": 2,
1540
+ "total": 2,
1541
+ "progress": 1.0,
1542
+ "tracking_system": "fallback",
1543
+ "error": str(e)
1544
+ }