synth-ai 0.2.12__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 (229) hide show
  1. examples/multi_step/configs/crafter_rl_outcome.toml +74 -0
  2. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +186 -0
  3. examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +83 -0
  4. examples/multi_step/configs/crafter_rl_stepwise_simple.toml +78 -0
  5. examples/multi_step/crafter_rl_lora.md +51 -10
  6. examples/multi_step/sse_metrics_streaming_notes.md +357 -0
  7. examples/multi_step/task_app_config_notes.md +7 -1
  8. examples/swe/task_app/grpo_swe_mini.py +55 -26
  9. examples/swe/task_app/hosted/rollout.py +40 -0
  10. examples/swe/task_app/hosted/test_service.py +5 -6
  11. examples/task_apps/TESTING.md +275 -0
  12. examples/task_apps/__init__.py +0 -0
  13. examples/task_apps/crafter/__init__.py +0 -0
  14. examples/task_apps/crafter/task_app/__init__.py +2 -0
  15. examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter.py +21 -46
  16. examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter_task_app.py +1 -1
  17. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/policy.py +60 -4
  18. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/openai_client.py +109 -45
  19. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/policy_routes.py +67 -49
  20. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/rollout.py +242 -193
  21. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_service.py +5 -6
  22. examples/task_apps/dev/pokemon_emerald/__init__.py +2 -0
  23. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/README.md +811 -0
  24. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/__init__.py +120 -0
  25. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/action.py +160 -0
  26. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/memory.py +155 -0
  27. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/perception.py +69 -0
  28. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/planning.py +96 -0
  29. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/simple.py +1502 -0
  30. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/system_prompt.py +4 -0
  31. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/grab_map.py +68 -0
  32. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/manual.py +216 -0
  33. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/__init__.py +35 -0
  34. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emerald_utils.py +631 -0
  35. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emulator.py +1544 -0
  36. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/enums.py +1428 -0
  37. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/memory_reader.py +4848 -0
  38. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/types.py +41 -0
  39. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/utils.py +298 -0
  40. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pyproject.toml +95 -0
  41. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/run.py +204 -0
  42. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/__init__.py +0 -0
  43. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/app.py +2152 -0
  44. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/client.py +429 -0
  45. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/frame_server.py +155 -0
  46. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/README.md +78 -0
  47. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/__init__.py +0 -0
  48. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/run_tests.py +122 -0
  49. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_direct.py +76 -0
  50. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_prompts.py +413 -0
  51. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_battle_state_formatting.py +204 -0
  52. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection.py +133 -0
  53. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection_comprehensive.py +229 -0
  54. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_direct_agent_emulator.py +300 -0
  55. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_fps_adjustment_pytest.py +205 -0
  56. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_direct.py +200 -0
  57. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_transition.py +284 -0
  58. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_map_ground_truth_comparison.py +468 -0
  59. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_memory_map.py +575 -0
  60. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_server_map_validation.py +311 -0
  61. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_torchic_state.py +259 -0
  62. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/__init__.py +0 -0
  63. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/anticheat.py +372 -0
  64. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/checkpoint.py +296 -0
  65. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/error_handler.py +275 -0
  66. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/get_local_ip.py +22 -0
  67. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/helpers.py +44 -0
  68. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/llm_logger.py +514 -0
  69. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_formatter.py +415 -0
  70. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher.py +1763 -0
  71. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher_singleton.py +33 -0
  72. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_trimmer.py +106 -0
  73. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_visualizer.py +334 -0
  74. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/ocr_dialogue.py +1020 -0
  75. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/recording.py +188 -0
  76. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/state_formatter.py +1481 -0
  77. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/vlm.py +862 -0
  78. examples/task_apps/dev/pokemon_emerald/modal_app.py +114 -0
  79. examples/task_apps/dev/pokemon_emerald/task_app/README.md +81 -0
  80. examples/task_apps/dev/pokemon_emerald/task_app/__init__.py +6 -0
  81. examples/task_apps/dev/pokemon_emerald/task_app/pokemon_emerald.py +685 -0
  82. examples/task_apps/enron/__init__.py +1 -0
  83. examples/task_apps/enron/eval_groq_qwen32.toml +16 -0
  84. examples/task_apps/enron/task_app/README.md +14 -0
  85. examples/task_apps/enron/task_app/__init__.py +1 -0
  86. examples/task_apps/enron/task_app/grpo_enron.py +906 -0
  87. examples/task_apps/enron/task_app/grpo_enron_task_app.py +146 -0
  88. examples/task_apps/enron/tests/__init__.py +2 -0
  89. examples/task_apps/enron/tests/conftest.py +115 -0
  90. examples/task_apps/enron/tests/integration/__init__.py +2 -0
  91. examples/task_apps/enron/tests/integration/test_enron_eval.py +177 -0
  92. examples/task_apps/enron/tests/integration/test_enron_rollout.py +135 -0
  93. examples/task_apps/enron/tests/unit/__init__.py +2 -0
  94. examples/task_apps/enron/tests/unit/test_enron_environment.py +126 -0
  95. examples/task_apps/math/__init__.py +0 -0
  96. examples/{rl/task_app → task_apps/math}/math_single_step.py +19 -10
  97. examples/task_apps/pokemon_battle/__init__.py +2 -0
  98. examples/task_apps/pokemon_battle/modal_app.py +104 -0
  99. examples/task_apps/pokemon_battle/task_app/README.md +68 -0
  100. examples/task_apps/pokemon_battle/task_app/__init__.py +6 -0
  101. examples/task_apps/pokemon_battle/task_app/pokemon_showdown.py +932 -0
  102. examples/task_apps/pokemon_red/README.md +357 -0
  103. examples/task_apps/pokemon_red/__init__.py +3 -0
  104. examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +225 -0
  105. examples/task_apps/pokemon_red/pallet_town_rl_config.toml +73 -0
  106. examples/task_apps/pokemon_red/task_app.py +606 -0
  107. examples/task_apps/pokemon_red/test_pallet_town_rewards.py +191 -0
  108. examples/task_apps/sokoban/README.md +307 -0
  109. examples/task_apps/sokoban/__init__.py +3 -0
  110. examples/task_apps/sokoban/eval_groq_qwen32.toml +16 -0
  111. examples/task_apps/sokoban/eval_openai_gpt5.toml +16 -0
  112. examples/task_apps/sokoban/task_app.py +1058 -0
  113. examples/task_apps/sokoban/tests/__init__.py +2 -0
  114. examples/task_apps/sokoban/tests/conftest.py +113 -0
  115. examples/task_apps/sokoban/tests/integration/__init__.py +2 -0
  116. examples/task_apps/sokoban/tests/integration/test_sokoban_eval.py +57 -0
  117. examples/task_apps/sokoban/tests/integration/test_sokoban_rollout.py +198 -0
  118. examples/task_apps/sokoban/tests/unit/__init__.py +2 -0
  119. examples/task_apps/sokoban/tests/unit/test_sokoban_environment.py +114 -0
  120. examples/task_apps/verilog/__init__.py +1 -0
  121. examples/task_apps/verilog/eval_groq_qwen32b.toml +20 -0
  122. examples/task_apps/verilog/task_app/README.md +12 -0
  123. examples/task_apps/verilog/task_app/__init__.py +1 -0
  124. examples/task_apps/verilog/task_app/grpo_verilog.py +931 -0
  125. examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +145 -0
  126. examples/task_apps/verilog/tests/__init__.py +2 -0
  127. examples/task_apps/verilog/tests/conftest.py +115 -0
  128. examples/task_apps/verilog/tests/integration/__init__.py +2 -0
  129. examples/task_apps/verilog/tests/integration/test_verilog_eval.py +179 -0
  130. examples/task_apps/verilog/tests/integration/test_verilog_rollout.py +55 -0
  131. examples/task_apps/verilog/tests/unit/__init__.py +2 -0
  132. examples/task_apps/verilog/tests/unit/test_verilog_scoring.py +118 -0
  133. examples/vlm/crafter_openai_vlm_agent.py +4 -4
  134. examples/vlm/run_crafter_vlm_benchmark.py +4 -4
  135. examples/warming_up_to_rl/configs/eval_stepwise_complex.toml +4 -2
  136. examples/warming_up_to_rl/configs/eval_stepwise_simple.toml +4 -2
  137. examples/warming_up_to_rl/run_eval.py +127 -18
  138. examples/workflows/__init__.py +0 -0
  139. examples/workflows/math_rl/__init__.py +0 -0
  140. examples/workflows/math_rl/download_dataset.py +80 -0
  141. synth_ai/__init__.py +41 -1
  142. synth_ai/api/train/builders.py +73 -29
  143. synth_ai/api/train/cli.py +12 -6
  144. synth_ai/api/train/configs/__init__.py +44 -0
  145. synth_ai/api/train/configs/rl.py +134 -0
  146. synth_ai/api/train/configs/sft.py +95 -0
  147. synth_ai/api/train/configs/shared.py +24 -0
  148. synth_ai/api/train/env_resolver.py +5 -2
  149. synth_ai/api/train/supported_algos.py +10 -5
  150. synth_ai/api/train/utils.py +7 -4
  151. synth_ai/cli/__init__.py +7 -51
  152. synth_ai/cli/_storage.py +4 -3
  153. synth_ai/cli/_validate_task_app.py +11 -0
  154. synth_ai/cli/balance.py +4 -3
  155. synth_ai/cli/calc.py +2 -2
  156. synth_ai/cli/demo.py +49 -43
  157. synth_ai/cli/legacy_root_backup.py +1 -1
  158. synth_ai/cli/rl_demo.py +86 -106
  159. synth_ai/cli/root.py +0 -97
  160. synth_ai/cli/task_apps.py +1710 -186
  161. synth_ai/demos/core/cli.py +121 -159
  162. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +28 -16
  163. synth_ai/environments/examples/crafter_classic/environment.py +16 -0
  164. synth_ai/environments/examples/enron/engine.py +7 -2
  165. synth_ai/environments/examples/enron/environment.py +68 -0
  166. synth_ai/environments/examples/red/engine.py +27 -0
  167. synth_ai/environments/examples/red/engine_helpers/memory_map.py +7 -0
  168. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_progression.py +477 -0
  169. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +32 -0
  170. synth_ai/environments/examples/red/environment.py +60 -0
  171. synth_ai/environments/examples/sokoban/taskset.py +116 -0
  172. synth_ai/environments/examples/verilog/engine.py +30 -4
  173. synth_ai/evals/__init__.py +15 -0
  174. synth_ai/evals/client.py +82 -0
  175. synth_ai/evals/types.py +42 -0
  176. synth_ai/jobs/client.py +16 -4
  177. synth_ai/judge_schemas.py +127 -0
  178. synth_ai/py.typed +0 -0
  179. synth_ai/task/__init__.py +14 -5
  180. synth_ai/task/contracts.py +124 -38
  181. synth_ai/task/proxy.py +48 -56
  182. synth_ai/task/rubrics/__init__.py +53 -0
  183. synth_ai/task/rubrics/loaders.py +133 -0
  184. synth_ai/task/rubrics/models.py +57 -0
  185. synth_ai/task/rubrics/scoring.py +113 -0
  186. synth_ai/task/rubrics/strict.py +149 -0
  187. synth_ai/task/server.py +8 -7
  188. synth_ai/task/validators.py +269 -6
  189. synth_ai/tracing_v3/decorators.py +7 -3
  190. synth_ai/tracing_v3/replica_sync.py +4 -4
  191. synth_ai/tracing_v3/serialization.py +130 -0
  192. synth_ai/tracing_v3/trace_utils.py +317 -0
  193. synth_ai/tracing_v3/turso/native_manager.py +3 -3
  194. {synth_ai-0.2.12.dist-info → synth_ai-0.2.13.dev2.dist-info}/METADATA +4 -1
  195. {synth_ai-0.2.12.dist-info → synth_ai-0.2.13.dev2.dist-info}/RECORD +228 -89
  196. {synth_ai-0.2.12.dist-info → synth_ai-0.2.13.dev2.dist-info}/entry_points.txt +0 -1
  197. synth_ai/task/rubrics.py +0 -219
  198. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/README.md +0 -0
  199. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/README.md +0 -0
  200. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/__init__.py +0 -0
  201. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/branching.py +0 -0
  202. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/environment_routes.py +0 -0
  203. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/__init__.py +0 -0
  204. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/__init__.py +0 -0
  205. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/app.py +0 -0
  206. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/environment.py +0 -0
  207. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/react_agent.py +0 -0
  208. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/shared.py +0 -0
  209. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/tools.py +0 -0
  210. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/hosted_app.py +0 -0
  211. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/__init__.py +0 -0
  212. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/main.py +0 -0
  213. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/registry.py +0 -0
  214. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/__init__.py +0 -0
  215. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/volume.py +0 -0
  216. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_agents.py +0 -0
  217. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/utils.py +0 -0
  218. /examples/{rl/task_app → task_apps/math}/README.md +0 -0
  219. /examples/{rl/task_app → task_apps/math}/math_task_app.py +0 -0
  220. /examples/{rl → workflows/math_rl}/configs/eval_base_qwen.toml +0 -0
  221. /examples/{rl → workflows/math_rl}/configs/eval_rl_qwen.toml +0 -0
  222. /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen.toml +0 -0
  223. /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen17.toml +0 -0
  224. /examples/{rl → workflows/math_rl}/configs/rl_from_ft_qwen.toml +0 -0
  225. /examples/{rl → workflows/math_rl}/run_eval.py +0 -0
  226. /examples/{rl → workflows/math_rl}/run_rl_and_save.py +0 -0
  227. {synth_ai-0.2.12.dist-info → synth_ai-0.2.13.dev2.dist-info}/WHEEL +0 -0
  228. {synth_ai-0.2.12.dist-info → synth_ai-0.2.13.dev2.dist-info}/licenses/LICENSE +0 -0
  229. {synth_ai-0.2.12.dist-info → synth_ai-0.2.13.dev2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2152 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Fixed Simple Pokemon Emerald server - headless FastAPI server
4
+ """
5
+
6
+ # Standard library imports
7
+ import base64
8
+ import datetime
9
+ import glob
10
+ import io
11
+ import json
12
+ import logging
13
+ import os
14
+ import signal
15
+ import sys
16
+ import threading
17
+ import time
18
+
19
+ # Third-party imports
20
+ import cv2
21
+ import numpy as np
22
+ import uvicorn
23
+ from fastapi import FastAPI, HTTPException, Request
24
+ from fastapi.middleware.cors import CORSMiddleware
25
+ from fastapi.responses import HTMLResponse, JSONResponse
26
+ from PIL import Image
27
+ from pydantic import BaseModel
28
+
29
+ # Add parent directory to path for local modules
30
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
31
+
32
+ # Local application imports
33
+ from pokemon_env.emulator import EmeraldEmulator
34
+ from utils.anticheat import AntiCheatTracker
35
+
36
+ # Set up logging - reduced verbosity for multiprocess mode
37
+ logging.basicConfig(level=logging.WARNING)
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Global state
41
+ env = None
42
+ anticheat_tracker = None # AntiCheat tracker for submission logging
43
+ step_counter = 0 # Track steps for submission logging
44
+ last_action_time = None # Track time of last action for decision time calculation
45
+ running = True
46
+ step_count = 0
47
+ agent_step_count = 0 # Track agent steps separately from frame steps
48
+ current_obs = None
49
+ fps = 80
50
+
51
+ # Performance monitoring
52
+ last_fps_log = time.time()
53
+ frame_count_since_log = 0
54
+ action_queue = [] # Queue for multi-action sequences
55
+ current_action = None # Current action being held
56
+ action_frames_remaining = 0 # Frames left to hold current action
57
+ release_frames_remaining = 0 # Frames left to wait after release
58
+
59
+ ### IMPORTANT: DO NOT REDUCE THESE OR BUTTONS MAY NOT WORK! ###
60
+ ACTION_HOLD_FRAMES = 12 # Hold each action for 12 frames
61
+ ACTION_RELEASE_DELAY = 24 # Delay between actions for processing
62
+
63
+ # Video recording state
64
+ video_writer = None
65
+ video_recording = False
66
+ video_filename = ""
67
+ video_frame_counter = 0
68
+ video_frame_skip = 4 # Record every 4th frame (120/4 = 30 FPS)
69
+
70
+ # Frame cache for separate frame server
71
+ # Use cache directory instead of /tmp
72
+ CACHE_DIR = ".pokeagent_cache"
73
+ os.makedirs(CACHE_DIR, exist_ok=True)
74
+ FRAME_CACHE_FILE = os.path.join(CACHE_DIR, "frame_cache.json")
75
+ frame_cache_counter = 0
76
+
77
+ # Server runs headless - display handled by client
78
+
79
+ # Threading locks for thread safety
80
+ obs_lock = threading.Lock()
81
+ step_lock = threading.Lock()
82
+ memory_lock = threading.Lock() # New lock for memory operations to prevent race conditions
83
+
84
+ # Background milestone processing
85
+ state_update_thread = None
86
+ state_update_running = False
87
+
88
+ # Button mapping removed - handled by client
89
+
90
+ # Video recording functions
91
+ def init_video_recording(record_enabled=False):
92
+ """Initialize video recording if enabled"""
93
+ global video_writer, video_recording, video_filename, fps, video_frame_skip
94
+
95
+ if not record_enabled:
96
+ return
97
+
98
+ try:
99
+ # Create video filename with timestamp
100
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
101
+ video_filename = f"pokegent_recording_{timestamp}.mp4"
102
+
103
+ # Video settings (GBA resolution is 240x160)
104
+ # Record at 30 FPS (skip every 4th frame from 120 FPS emulator)
105
+ recording_fps = fps / video_frame_skip # 120 / 4 = 30 FPS
106
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
107
+ video_writer = cv2.VideoWriter(video_filename, fourcc, float(recording_fps), (240, 160))
108
+
109
+ if video_writer.isOpened():
110
+ video_recording = True
111
+ print(f"📹 Video recording started: {video_filename} at {recording_fps:.0f} FPS (recording every {video_frame_skip} frames)")
112
+ else:
113
+ print("❌ Failed to initialize video recording")
114
+ video_writer = None
115
+
116
+ except Exception as e:
117
+ print(f"❌ Video recording initialization error: {e}")
118
+ video_writer = None
119
+
120
+ def update_frame_cache(screenshot):
121
+ """Update the frame cache file for the separate frame server"""
122
+ global frame_cache_counter, FRAME_CACHE_FILE
123
+
124
+ if screenshot is None:
125
+ return
126
+
127
+ try:
128
+ # Convert screenshot to base64
129
+ if hasattr(screenshot, 'save'): # PIL image
130
+ buffer = io.BytesIO()
131
+ screenshot.save(buffer, format='PNG')
132
+ img_str = base64.b64encode(buffer.getvalue()).decode()
133
+ elif isinstance(screenshot, np.ndarray): # Numpy array
134
+ pil_image = Image.fromarray(screenshot)
135
+ buffer = io.BytesIO()
136
+ pil_image.save(buffer, format='PNG')
137
+ img_str = base64.b64encode(buffer.getvalue()).decode()
138
+ else:
139
+ return
140
+
141
+ frame_cache_counter += 1
142
+
143
+ # Write to cache file atomically
144
+ cache_data = {
145
+ "frame_data": img_str,
146
+ "frame_counter": frame_cache_counter,
147
+ "timestamp": time.time()
148
+ }
149
+
150
+ # Write to temporary file first, then move (atomic operation)
151
+ temp_file = FRAME_CACHE_FILE + ".tmp"
152
+ with open(temp_file, 'w') as f:
153
+ json.dump(cache_data, f)
154
+ os.rename(temp_file, FRAME_CACHE_FILE)
155
+
156
+ except Exception as e:
157
+ pass # Silently handle cache write errors
158
+
159
+ def record_frame(screenshot):
160
+ """Record frame to video if recording is enabled with frame skipping"""
161
+ global video_writer, video_recording, video_frame_counter, video_frame_skip
162
+
163
+ if not video_recording or video_writer is None or screenshot is None:
164
+ return
165
+
166
+ # Increment frame counter
167
+ video_frame_counter += 1
168
+
169
+ # Only record every Nth frame based on frame skip
170
+ if video_frame_counter % video_frame_skip != 0:
171
+ return
172
+
173
+ try:
174
+ # Convert PIL image to OpenCV format
175
+ if hasattr(screenshot, 'save'): # PIL image
176
+ # Convert PIL to numpy array
177
+ frame_array = np.array(screenshot)
178
+ # Convert RGB to BGR for OpenCV
179
+ frame_bgr = cv2.cvtColor(frame_array, cv2.COLOR_RGB2BGR)
180
+ video_writer.write(frame_bgr)
181
+ elif isinstance(screenshot, np.ndarray): # Already numpy array
182
+ # Convert RGB to BGR for OpenCV if needed
183
+ if screenshot.shape[2] == 3: # RGB
184
+ frame_bgr = cv2.cvtColor(screenshot, cv2.COLOR_RGB2BGR)
185
+ else:
186
+ frame_bgr = screenshot
187
+ video_writer.write(frame_bgr)
188
+
189
+ except Exception as e:
190
+ import logging
191
+ logger = logging.getLogger(__name__)
192
+ logger.debug(f"Video recording frame error: {e}")
193
+
194
+ def cleanup_video_recording():
195
+ """Clean up video recording resources"""
196
+ global video_writer, video_recording
197
+
198
+ if video_recording and video_writer is not None:
199
+ try:
200
+ video_writer.release()
201
+ print(f"📹 Video recording saved: {video_filename}")
202
+ except Exception as e:
203
+ print(f"❌ Error saving video recording: {e}")
204
+ finally:
205
+ video_writer = None
206
+ video_recording = False
207
+
208
+ # Milestone tracking is now handled by the emulator
209
+
210
+ # FastAPI app
211
+ app = FastAPI(
212
+ title="PokeAgent Challenge",
213
+ description="Streamer display FastAPI endpoints",
214
+ version="3.0.0-preview",
215
+ )
216
+
217
+ # Add CORS middleware
218
+ app.add_middleware(
219
+ CORSMiddleware,
220
+ allow_origins=["*"],
221
+ allow_credentials=True,
222
+ allow_methods=["*"],
223
+ allow_headers=["*"],
224
+ )
225
+
226
+ # Models for API requests and responses
227
+ class ActionRequest(BaseModel):
228
+ buttons: list = [] # List of button names: A, B, SELECT, START, UP, DOWN, LEFT, RIGHT
229
+
230
+ class GameStateResponse(BaseModel):
231
+ screenshot_base64: str
232
+ step_number: int
233
+ resolution: list # [width, height]
234
+ status: str
235
+
236
+ class ComprehensiveStateResponse(BaseModel):
237
+ visual: dict
238
+ player: dict
239
+ game: dict
240
+ map: dict
241
+ milestones: dict = {}
242
+ location_connections: dict = {} # Add location connections for portal display
243
+ step_number: int
244
+ status: str
245
+ action_queue_length: int = 0
246
+
247
+ def periodic_milestone_updater():
248
+ """Lightweight background thread that only updates milestones occasionally"""
249
+ global state_update_running
250
+
251
+ last_milestone_update = 0
252
+
253
+ while state_update_running and running:
254
+ try:
255
+ current_time = time.time()
256
+
257
+ # Update milestones only every 5 seconds (much less frequent)
258
+ if current_time - last_milestone_update >= 5.0:
259
+ if env and env.memory_reader:
260
+ try:
261
+ # Use lightweight state for milestone updates only
262
+ basic_state = {
263
+ "player": {
264
+ "money": env.get_money(),
265
+ "party_size": len(env.get_party_pokemon() or []),
266
+ "position": env.get_coordinates()
267
+ },
268
+ "map": {
269
+ "location": env.get_location()
270
+ }
271
+ }
272
+ env.check_and_update_milestones(basic_state)
273
+ last_milestone_update = current_time
274
+ logger.debug("Lightweight milestone update completed")
275
+ except Exception as e:
276
+ logger.debug(f"Milestone update failed: {e}")
277
+
278
+ # Sleep for 1 second between checks
279
+ time.sleep(1.0)
280
+
281
+ except Exception as e:
282
+ logger.error(f"Error in milestone updater: {e}")
283
+ time.sleep(5.0) # Wait longer on error
284
+
285
+ def signal_handler(signum, frame):
286
+ """Handle shutdown signals gracefully"""
287
+ global running, state_update_running
288
+ print(f"\nReceived signal {signum}, shutting down gracefully...")
289
+ running = False
290
+ state_update_running = False
291
+ cleanup_video_recording()
292
+ if env:
293
+ env.stop()
294
+ sys.exit(0)
295
+
296
+ def setup_environment(skip_initial_state=False):
297
+ """Initialize the emulator"""
298
+ global env, current_obs, anticheat_tracker
299
+
300
+ try:
301
+ rom_path = "Emerald-GBAdvance/rom.gba"
302
+ if not os.path.exists(rom_path):
303
+ raise RuntimeError(f"ROM not found at {rom_path}")
304
+
305
+ env = EmeraldEmulator(rom_path=rom_path)
306
+ env.initialize()
307
+
308
+ # Initialize AntiCheat tracker for submission logging
309
+ anticheat_tracker = AntiCheatTracker()
310
+ anticheat_tracker.initialize_submission_log("SERVER_MODE")
311
+ print("AntiCheat tracker initialized for submission logging")
312
+
313
+ # Log initial GAME_RUNNING milestone at startup (STEP=0, time=0)
314
+ # Skip this if we're going to load a state anyway
315
+ if not skip_initial_state:
316
+ try:
317
+ # Mark GAME_RUNNING milestone as completed immediately
318
+ env.milestone_tracker.mark_completed("GAME_RUNNING")
319
+
320
+ # Get initial game state for logging
321
+ initial_state = env.get_comprehensive_state()
322
+
323
+ # Create state hash
324
+ import hashlib
325
+ state_str = str(initial_state)
326
+ state_hash = hashlib.md5(state_str.encode()).hexdigest()[:8]
327
+
328
+ # Log initial entry with GAME_RUNNING milestone
329
+ anticheat_tracker.log_submission_data(
330
+ step=0,
331
+ state_data=initial_state,
332
+ action_taken="INIT",
333
+ decision_time=0.0,
334
+ state_hash=state_hash,
335
+ manual_mode=True,
336
+ milestone_override="GAME_RUNNING"
337
+ )
338
+ print("Initial GAME_RUNNING milestone logged at startup")
339
+
340
+ except Exception as e:
341
+ print(f"Warning: Could not log initial milestone: {e}")
342
+
343
+ screenshot = env.get_screenshot()
344
+ if screenshot:
345
+ with obs_lock:
346
+ current_obs = np.array(screenshot)
347
+ else:
348
+ with obs_lock:
349
+ current_obs = np.zeros((env.height, env.width, 3), dtype=np.uint8)
350
+
351
+ print("Emulator initialized successfully!")
352
+ return True
353
+
354
+ except Exception as e:
355
+ print(f"Failed to initialize emulator: {e}")
356
+ return False
357
+
358
+ def handle_input(manual_mode=False):
359
+ """Handle input - server runs headless, no input handling needed"""
360
+ # Server always runs headless - input handled by client via HTTP API
361
+ return True, []
362
+
363
+ def step_environment(actions_pressed):
364
+ """Take a step in the environment with optimized locking for better performance"""
365
+ global current_obs
366
+
367
+ # Debug: print what actions are being sent to emulator
368
+ # if actions_pressed:
369
+ # print( Stepping emulator with actions: {actions_pressed}")
370
+
371
+
372
+ # Only use memory_lock for the essential emulator step
373
+ with memory_lock:
374
+ env.run_frame_with_buttons(actions_pressed)
375
+
376
+ # Do lightweight area transition detection inside the lock
377
+ if hasattr(env, 'memory_reader') and env.memory_reader:
378
+ try:
379
+ transition_detected = env.memory_reader._check_area_transition()
380
+ if transition_detected:
381
+ logger.info("Area transition detected")
382
+ env.memory_reader.invalidate_map_cache()
383
+ if hasattr(env.memory_reader, '_cached_behaviors'):
384
+ env.memory_reader._cached_behaviors = None
385
+ if hasattr(env.memory_reader, '_cached_behaviors_map_key'):
386
+ env.memory_reader._cached_behaviors_map_key = None
387
+ # Set flag to trigger map stitcher update outside the lock
388
+ env.memory_reader._area_transition_detected = True
389
+ except Exception as e:
390
+ logger.warning(f"Area transition check failed: {e}")
391
+
392
+ # Update screenshot outside the memory lock to reduce contention
393
+ try:
394
+ screenshot = env.get_screenshot()
395
+ if screenshot:
396
+ record_frame(screenshot)
397
+ update_frame_cache(screenshot) # Update frame cache for separate frame server
398
+ with obs_lock:
399
+ current_obs = np.array(screenshot)
400
+
401
+ # Update map stitcher on position changes (lightweight approach)
402
+ # This ensures map data stays current as player moves
403
+ if hasattr(env, 'memory_reader') and env.memory_reader:
404
+ try:
405
+ # Check if player position has changed
406
+ should_update = False
407
+
408
+ # Get current player coordinates and map info
409
+ current_coords = env.memory_reader.read_coordinates()
410
+ current_map_bank = env.memory_reader._read_u8(env.memory_reader.addresses.MAP_BANK)
411
+ current_map_number = env.memory_reader._read_u8(env.memory_reader.addresses.MAP_NUMBER)
412
+ current_map_info = (current_map_bank, current_map_number)
413
+
414
+ # Initialize tracking variables if needed
415
+ if not hasattr(env, '_last_player_coords'):
416
+ env._last_player_coords = None
417
+ env._last_map_info = None
418
+
419
+ # Check for position changes
420
+ if current_coords != env._last_player_coords or current_map_info != env._last_map_info:
421
+ should_update = True
422
+ env._last_player_coords = current_coords
423
+ env._last_map_info = current_map_info
424
+ print(f"📍 Position change detected: {current_coords}, map: {current_map_info}")
425
+ logger.debug(f"Map stitcher update triggered by position change: {current_coords}, map: {current_map_info}")
426
+
427
+ # Always update on area transitions (already detected above)
428
+ if hasattr(env.memory_reader, '_area_transition_detected') and env.memory_reader._area_transition_detected:
429
+ should_update = True
430
+ env.memory_reader._area_transition_detected = False # Reset flag
431
+ logger.debug("Map stitcher update triggered by area transition")
432
+
433
+ # Update map stitcher directly when position changes
434
+ if should_update:
435
+ # @TODO should do location change warps here too
436
+ print(f"🗺️ Triggering map stitcher update for position change")
437
+ # Call map stitcher update directly without full map reading
438
+ tiles = env.memory_reader.read_map_around_player(radius=7)
439
+ if tiles:
440
+ print(f"🗺️ Got {len(tiles)} tiles, updating map stitcher")
441
+ state = {"map": {}} # Basic state for stitcher
442
+ env.memory_reader._update_map_stitcher(tiles, state)
443
+ logger.debug("Map stitcher updated for position change")
444
+ print(f"✅ Map stitcher update completed")
445
+ else:
446
+ print(f"❌ No tiles found for map stitcher update")
447
+
448
+ except Exception as e:
449
+ logger.error(f"Failed to update map stitcher during movement: {e}")
450
+ print(f"❌ Map stitcher update failed: {e}")
451
+ except Exception as e:
452
+ logger.warning(f"Error updating screenshot: {e}")
453
+
454
+ def update_display(manual_mode=False):
455
+ """Update display - server runs headless, no display update needed"""
456
+ # Server runs headless - display handled by client
457
+ pass
458
+
459
+ def draw_info_overlay():
460
+ """Draw info overlay - server runs headless, no overlay needed"""
461
+ # Server runs headless - overlay handled by client
462
+ pass
463
+
464
+ def save_screenshot():
465
+ """Save current screenshot"""
466
+ global current_obs
467
+
468
+ with obs_lock:
469
+ obs_copy = current_obs.copy() if current_obs is not None else None
470
+
471
+ if obs_copy is not None:
472
+ timestamp = int(time.time())
473
+ filename = f"simple_test_screenshot_{timestamp}.png"
474
+ img = Image.fromarray(obs_copy)
475
+ img.save(filename)
476
+ print(f"Screenshot saved: {filename}")
477
+
478
+ def reset_game():
479
+ """Reset the game and all milestones"""
480
+ global env, step_count
481
+
482
+ print("Resetting game and milestones...")
483
+ with step_lock:
484
+ env.initialize()
485
+ env.milestone_tracker.reset_all() # Reset all milestones
486
+ step_count = 0
487
+ print("Game and milestone reset complete")
488
+
489
+ def game_loop(manual_mode=False):
490
+ """Main game loop - runs in main thread, always headless"""
491
+ global running, step_count
492
+
493
+ print("Starting headless game loop...")
494
+
495
+ while running:
496
+ # Handle input
497
+ should_continue, actions_pressed = handle_input(manual_mode)
498
+ if not should_continue:
499
+ break
500
+
501
+ # In server mode, handle action queue with proper button hold timing
502
+ action_completed = False
503
+ if not manual_mode:
504
+ global current_action, action_frames_remaining, release_frames_remaining
505
+
506
+ if current_action and action_frames_remaining > 0:
507
+ # Continue holding the current action
508
+ actions_pressed = [current_action]
509
+ action_frames_remaining -= 1
510
+ if action_frames_remaining == 0:
511
+ # Action finished, start release delay
512
+ current_action = None
513
+ release_frames_remaining = ACTION_RELEASE_DELAY
514
+ action_completed = True # Mark action as completed
515
+ print(f"✅ Action completed: step_count will increment")
516
+ elif release_frames_remaining > 0:
517
+ # Release delay (no button pressed)
518
+ actions_pressed = []
519
+ release_frames_remaining -= 1
520
+ elif action_queue:
521
+ # Start a new action from the queue
522
+ current_action = action_queue.pop(0)
523
+ action_frames_remaining = ACTION_HOLD_FRAMES
524
+ actions_pressed = [current_action]
525
+ queue_len = len(action_queue)
526
+ # Get current FPS for estimation
527
+ current_fps_for_calc = env.get_current_fps(fps) if env else fps
528
+ estimated_time = queue_len * (ACTION_HOLD_FRAMES + ACTION_RELEASE_DELAY) / current_fps_for_calc
529
+ print(f"🎮 Server processing action: {current_action}, Queue remaining: {queue_len} actions (~{estimated_time:.1f}s)")
530
+ else:
531
+ # No action to process
532
+ actions_pressed = []
533
+
534
+ # Step environment
535
+ step_environment(actions_pressed)
536
+
537
+ # Milestones are now updated in background thread
538
+
539
+ # Server runs headless - no display update needed
540
+ update_display(manual_mode)
541
+
542
+ # Only increment step count when an action is completed
543
+ if action_completed:
544
+ with step_lock:
545
+ step_count += 1
546
+ print(f"📈 Step count incremented to: {step_count}")
547
+
548
+ # Performance monitoring - log actual FPS every 5 seconds
549
+ global last_fps_log, frame_count_since_log
550
+ frame_count_since_log += 1
551
+ current_time = time.time()
552
+ if current_time - last_fps_log >= 5.0: # Log every 5 seconds
553
+ actual_fps = frame_count_since_log / (current_time - last_fps_log)
554
+ queue_len = len(action_queue)
555
+ print(f"📊 Server FPS: {actual_fps:.1f} (target: {fps}), Queue: {queue_len} actions")
556
+ last_fps_log = current_time
557
+ frame_count_since_log = 0
558
+
559
+ # Use dynamic FPS - 2x speed during dialog
560
+ current_fps = env.get_current_fps(fps) if env else fps
561
+ # Server runs headless - always use sleep for timing
562
+ time.sleep(1.0 / current_fps)
563
+
564
+ def run_fastapi_server(port):
565
+ """Run FastAPI server in background thread"""
566
+ uvicorn.run(
567
+ app,
568
+ host="0.0.0.0",
569
+ port=port,
570
+ log_level="error",
571
+ access_log=False,
572
+ timeout_keep_alive=60, # Keep connections alive longer
573
+ timeout_graceful_shutdown=30 # More time for graceful shutdown
574
+ )
575
+
576
+ # Serve stream.html
577
+ @app.get("/stream")
578
+ async def get_stream():
579
+ """Serve the stream.html interface"""
580
+ try:
581
+ with open("server/stream.html", "r") as f:
582
+ content = f.read()
583
+ return HTMLResponse(content=content)
584
+ except FileNotFoundError:
585
+ raise HTTPException(status_code=404, detail="Stream interface not found")
586
+
587
+ # FastAPI endpoints
588
+ @app.get("/health")
589
+ async def get_health():
590
+ """Health check endpoint for server monitoring"""
591
+ return {"status": "healthy", "timestamp": time.time()}
592
+
593
+ @app.get("/status")
594
+ async def get_status():
595
+ """Get server status"""
596
+ with step_lock:
597
+ current_step = step_count
598
+
599
+ # Get current FPS (may be 4x during dialog)
600
+ current_fps = env.get_current_fps(fps) if env else fps
601
+ # Use cached dialog state for consistency with FPS calculation
602
+ is_dialog = env._cached_dialog_state if env else False
603
+
604
+ return {
605
+ "status": "running",
606
+ "step_count": current_step,
607
+ "base_fps": fps,
608
+ "current_fps": current_fps,
609
+ "is_dialog": is_dialog,
610
+ "fps_multiplier": 2 if is_dialog else 1
611
+ }
612
+
613
+ @app.get("/screenshot")
614
+ async def get_screenshot():
615
+ """Get current screenshot"""
616
+ global current_obs, step_count
617
+
618
+ if env is None:
619
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
620
+
621
+ with obs_lock:
622
+ obs_copy = current_obs.copy() if current_obs is not None else None
623
+
624
+ if obs_copy is None:
625
+ raise HTTPException(status_code=500, detail="No screenshot available")
626
+
627
+ try:
628
+ # Convert numpy array to PIL image
629
+ pil_image = Image.fromarray(obs_copy)
630
+
631
+ # Convert to base64
632
+ buffer = io.BytesIO()
633
+ pil_image.save(buffer, format='PNG')
634
+ img_str = base64.b64encode(buffer.getvalue()).decode()
635
+
636
+ with step_lock:
637
+ current_step = step_count
638
+
639
+ return GameStateResponse(
640
+ screenshot_base64=img_str,
641
+ step_number=current_step,
642
+ resolution=[obs_copy.shape[1], obs_copy.shape[0]],
643
+ status="running"
644
+ )
645
+
646
+ except Exception as e:
647
+ logger.error(f"Error getting screenshot: {e}")
648
+ raise HTTPException(status_code=500, detail=str(e))
649
+
650
+ @app.get("/api/frame")
651
+ async def get_latest_frame():
652
+ """Get latest game frame in same format as single-process mode"""
653
+ global current_obs, env
654
+
655
+ with obs_lock:
656
+ obs_copy = current_obs.copy() if current_obs is not None else None
657
+
658
+ # If current_obs is None (e.g., after server restart), try to get a fresh screenshot
659
+ if obs_copy is None and env:
660
+ try:
661
+ screenshot = env.get_screenshot()
662
+ if screenshot:
663
+ obs_copy = np.array(screenshot)
664
+ # Update current_obs for future requests
665
+ with obs_lock:
666
+ current_obs = obs_copy.copy()
667
+ logger.debug("Frame endpoint: Retrieved fresh screenshot after restart")
668
+ except Exception as e:
669
+ logger.warning(f"Frame endpoint: Failed to get fresh screenshot: {e}")
670
+
671
+ if obs_copy is None:
672
+ return {"frame": ""}
673
+
674
+ try:
675
+ # Convert to base64
676
+ pil_image = Image.fromarray(obs_copy)
677
+ buffer = io.BytesIO()
678
+ pil_image.save(buffer, format='PNG')
679
+ img_str = base64.b64encode(buffer.getvalue()).decode()
680
+
681
+ return {"frame": img_str}
682
+ except Exception as e:
683
+ logger.warning(f"Frame endpoint: Error encoding frame: {e}")
684
+ return {"frame": ""}
685
+
686
+ @app.post("/action")
687
+ async def take_action(request: ActionRequest):
688
+ """Take an action"""
689
+ global current_obs, step_count, recent_button_presses, action_queue, anticheat_tracker, step_counter, last_action_time
690
+
691
+ # print( Action endpoint called with request: {request}")
692
+ # print( Request buttons: {request.buttons}")
693
+
694
+ if env is None:
695
+ # print( Emulator not initialized")
696
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
697
+
698
+ try:
699
+ # Add all actions to the queue (handle both single actions and lists)
700
+ if request.buttons:
701
+ # Add ALL actions to the queue - let the game loop handle execution
702
+ print(f"📡 Server received actions: {request.buttons}")
703
+ print(f"📋 Action queue before extend: {action_queue}")
704
+ action_queue.extend(request.buttons)
705
+ print(f"📋 Action queue after extend: {action_queue}")
706
+
707
+ # Track button presses for recent actions display
708
+ current_time = time.time()
709
+ for button in request.buttons:
710
+ # Add all buttons to recent actions (removed duplicate filtering for debugging)
711
+ recent_button_presses.append({
712
+ "button": button,
713
+ "timestamp": current_time
714
+ })
715
+
716
+ # Update total actions count in metrics
717
+ with step_lock:
718
+ latest_metrics["total_actions"] = latest_metrics.get("total_actions", 0) + len(request.buttons)
719
+
720
+ # Also update the LLM logger's action count for checkpoint persistence
721
+ try:
722
+ from utils.llm_logger import get_llm_logger
723
+ llm_logger = get_llm_logger()
724
+ if llm_logger:
725
+ llm_logger.cumulative_metrics["total_actions"] = latest_metrics["total_actions"]
726
+
727
+ # Sync LLM logger's cumulative metrics back to latest_metrics
728
+ # This ensures token usage and costs from LLM interactions are displayed
729
+ cumulative_metrics_to_sync = ["total_tokens", "prompt_tokens", "completion_tokens", "total_cost", "total_llm_calls", "total_run_time"]
730
+ for metric_key in cumulative_metrics_to_sync:
731
+ if metric_key in llm_logger.cumulative_metrics:
732
+ latest_metrics[metric_key] = llm_logger.cumulative_metrics[metric_key]
733
+ except Exception as e:
734
+ logger.debug(f"Failed to sync metrics with LLM logger: {e}")
735
+
736
+ # Keep only last 50 button presses to avoid memory issues
737
+ if len(recent_button_presses) > 50:
738
+ recent_button_presses = recent_button_presses[-50:]
739
+ else:
740
+ print(f" No buttons in request")
741
+
742
+ # DON'T execute action here - let the game loop handle it from the queue
743
+ # This prevents conflicts between the API thread and pygame thread
744
+
745
+ # Return immediate success - avoid all locks to prevent deadlocks
746
+ actions_added = len(request.buttons) if request.buttons else 0
747
+
748
+ # print( Returning success, actions_added: {actions_added}, queue_length: {len(action_queue)}")
749
+
750
+ # Log action to submission.log if anticheat tracker is available
751
+ if anticheat_tracker and request.buttons:
752
+ try:
753
+ # Calculate decision time
754
+ current_time = time.time()
755
+ if last_action_time is not None:
756
+ decision_time = current_time - last_action_time
757
+ else:
758
+ decision_time = 0.0 # First action
759
+ last_action_time = current_time
760
+
761
+ # Get current game state for logging
762
+ game_state = env.get_comprehensive_state()
763
+ action_taken = request.buttons[0] if request.buttons else "NONE" # Log first action
764
+
765
+ # Create simple state hash
766
+ import hashlib
767
+ state_str = str(game_state)
768
+ state_hash = hashlib.md5(state_str.encode()).hexdigest()[:8]
769
+
770
+ # Determine if this is manual mode (from client) or agent mode
771
+ # For now, assume manual mode if coming through API
772
+ manual_mode = request.source == "manual" if hasattr(request, 'source') else True
773
+
774
+ # Get the latest milestone from the emulator's milestone tracker
775
+ # First, trigger an immediate milestone check to ensure current state is detected
776
+ latest_milestone = "NONE"
777
+ if env and hasattr(env, 'milestone_tracker'):
778
+ try:
779
+ # Force an immediate milestone check before logging
780
+ env.check_and_update_milestones(game_state)
781
+ except Exception as e:
782
+ logger.debug(f"Error during immediate milestone check: {e}")
783
+
784
+ milestone_name, split_time, total_time = env.milestone_tracker.get_latest_milestone_info()
785
+ latest_milestone = milestone_name if milestone_name != "NONE" else "NONE"
786
+
787
+ # Log the action
788
+ step_counter += 1
789
+ anticheat_tracker.log_submission_data(
790
+ step=step_counter,
791
+ state_data=game_state,
792
+ action_taken=action_taken,
793
+ decision_time=decision_time,
794
+ state_hash=state_hash,
795
+ manual_mode=manual_mode,
796
+ milestone_override=latest_milestone
797
+ )
798
+
799
+ except Exception as e:
800
+ logger.warning(f"Error logging to submission.log: {e}")
801
+
802
+ # Return lightweight response without any lock acquisition
803
+ return {
804
+ "status": "success",
805
+ "actions_queued": actions_added,
806
+ "queue_length": len(action_queue), # action_queue access is atomic for lists
807
+ "message": f"Added {actions_added} actions to queue"
808
+ }
809
+
810
+ except Exception as e:
811
+ # print( Exception in action endpoint: {e}")
812
+ logger.error(f"Error taking action: {e}")
813
+ raise HTTPException(status_code=500, detail=str(e))
814
+
815
+
816
+ @app.get("/queue_status")
817
+ async def get_queue_status():
818
+ """Get action queue status"""
819
+ global action_queue, current_action, action_frames_remaining, release_frames_remaining
820
+
821
+ queue_empty = (len(action_queue) == 0 and
822
+ current_action is None and
823
+ action_frames_remaining == 0 and
824
+ release_frames_remaining == 0)
825
+
826
+ return {
827
+ "queue_empty": queue_empty,
828
+ "queue_length": len(action_queue),
829
+ "current_action": current_action,
830
+ "action_frames_remaining": action_frames_remaining,
831
+ "release_frames_remaining": release_frames_remaining
832
+ }
833
+
834
+ @app.get("/state")
835
+ async def get_comprehensive_state():
836
+ """Get comprehensive game state including visual and memory data"""
837
+ if env is None:
838
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
839
+
840
+ try:
841
+ # Use the emulator's built-in caching (100ms cache)
842
+ # This avoids expensive operations on rapid requests
843
+ state = env.get_comprehensive_state()
844
+
845
+ # Ensure game state is consistent with cached dialog state
846
+ # Use the same cached dialog state as the status endpoint
847
+ is_dialog = env._cached_dialog_state if env else False
848
+ if is_dialog:
849
+ state["game"]["game_state"] = "dialog"
850
+ else:
851
+ # Force overworld if not in dialog (respect 5-second timeout)
852
+ state["game"]["game_state"] = "overworld"
853
+
854
+ # Include milestones for storyline objective auto-completion
855
+ if env.milestone_tracker:
856
+ state["milestones"] = env.milestone_tracker.milestones
857
+
858
+ # Get map stitcher data for enhanced map display
859
+ # Use the memory_reader's MapStitcher instance which has the accumulated data
860
+ map_stitcher = None
861
+ if env and env.memory_reader and hasattr(env.memory_reader, '_map_stitcher'):
862
+ map_stitcher = env.memory_reader._map_stitcher
863
+ num_areas = len(map_stitcher.map_areas) if map_stitcher and hasattr(map_stitcher, 'map_areas') else 0
864
+ logger.debug(f"Using memory_reader's MapStitcher with {num_areas} areas")
865
+ else:
866
+ logger.debug("No MapStitcher available from memory_reader")
867
+
868
+ # Get current location name
869
+ current_location = state.get("player", {}).get("location", "Unknown")
870
+ player_pos = state.get("player", {}).get("position")
871
+ if player_pos:
872
+ player_coords = (player_pos.get("x", 0), player_pos.get("y", 0))
873
+ else:
874
+ player_coords = None
875
+
876
+ # Add stitched map info to the map section
877
+ if not "map" in state:
878
+ state["map"] = {}
879
+
880
+ # Check if visual_map was already generated by memory_reader
881
+ # If so, preserve it as it has the proper accumulated map data
882
+ visual_map_from_memory_reader = state.get("map", {}).get("visual_map")
883
+ if visual_map_from_memory_reader:
884
+ logger.debug("Using visual_map generated by memory_reader")
885
+ # Keep the visual_map as-is
886
+ elif map_stitcher:
887
+ # Generate visual map if not already present
888
+ try:
889
+ # Get NPCs from state if available
890
+ npcs = state.get("map", {}).get("object_events", [])
891
+
892
+ # Get connections for this location
893
+ connections_with_coords = []
894
+ if current_location and current_location != "Unknown":
895
+ location_connections = map_stitcher.get_location_connections(current_location)
896
+ for conn in location_connections:
897
+ if len(conn) >= 3:
898
+ other_loc, my_coords, their_coords = conn[0], conn[1], conn[2]
899
+ connections_with_coords.append({
900
+ "to": other_loc,
901
+ "from_pos": list(my_coords) if my_coords else [],
902
+ "to_pos": list(their_coords) if their_coords else []
903
+ })
904
+
905
+ # Generate the map display
906
+ map_lines = map_stitcher.generate_location_map_display(
907
+ location_name=current_location,
908
+ player_pos=player_coords,
909
+ npcs=npcs,
910
+ connections=connections_with_coords
911
+ )
912
+
913
+ # Store as formatted text
914
+ if map_lines:
915
+ state["map"]["visual_map"] = "\n".join(map_lines)
916
+ logger.debug(f"Generated visual_map with {len(map_lines)} lines")
917
+ except Exception as e:
918
+ logger.error(f"Failed to generate visual_map: {e}")
919
+
920
+ # Add stitched map info for the client/frontend
921
+ if map_stitcher:
922
+ # Get the location grid and connections
923
+ if current_location and current_location != "Unknown":
924
+ location_grid = map_stitcher.get_location_grid(current_location)
925
+ connections = []
926
+
927
+ # Get connections for this location
928
+ for other_loc, my_coords, their_coords in map_stitcher.get_location_connections(current_location):
929
+ connections.append({
930
+ "to": other_loc,
931
+ "from_pos": list(my_coords),
932
+ "to_pos": list(their_coords)
933
+ })
934
+
935
+ state["map"]["stitched_map_info"] = {
936
+ "available": True,
937
+ "current_area": {
938
+ "name": current_location,
939
+ "connections": connections,
940
+ "player_pos": player_coords
941
+ },
942
+ "player_local_pos": player_coords
943
+ }
944
+ else:
945
+ state["map"]["stitched_map_info"] = {
946
+ "available": False,
947
+ "reason": "Unknown location"
948
+ }
949
+
950
+ # Also include location connections directly for backward compatibility
951
+ try:
952
+ cache_file = ".pokeagent_cache/map_stitcher_data.json"
953
+ if os.path.exists(cache_file):
954
+ with open(cache_file, 'r') as f:
955
+ map_data = json.load(f)
956
+ if 'location_connections' in map_data and map_data['location_connections']:
957
+ location_connections = map_data['location_connections']
958
+ state["location_connections"] = location_connections
959
+ logger.debug(f"Loaded location connections for {len(location_connections) if location_connections else 0} locations")
960
+ elif 'warp_connections' in map_data and map_data['warp_connections']:
961
+ # Convert warp_connections to portal_connections format for LLM display
962
+ map_id_connections = {}
963
+ for conn in map_data['warp_connections']:
964
+ from_map = conn['from_map_id']
965
+ if from_map not in map_id_connections:
966
+ map_id_connections[from_map] = []
967
+
968
+ # Find the location name for the destination map
969
+ to_map_name = "Unknown Location"
970
+ if str(conn['to_map_id']) in map_data.get('map_areas', {}):
971
+ to_map_name = map_data['map_areas'][str(conn['to_map_id'])]['location_name']
972
+
973
+ map_id_connections[from_map].append({
974
+ 'to_name': to_map_name,
975
+ 'from_pos': conn['from_position'], # Keep as list for JSON serialization
976
+ 'to_pos': conn['to_position'] # Keep as list for JSON serialization
977
+ })
978
+
979
+ state["portal_connections"] = map_id_connections
980
+ print(f"🗺️ SERVER: Added portal connections to state: {map_id_connections}")
981
+ print(f"🗺️ SERVER: State now has keys: {list(state.keys())}")
982
+ logger.debug(f"Loaded portal connections for {len(map_id_connections) if map_id_connections else 0} maps from persistent storage")
983
+ else:
984
+ print(f"🗺️ SERVER: No warp connections found in map data")
985
+ logger.debug("No warp connections found in map stitcher data")
986
+ else:
987
+ print(f"🗺️ SERVER: Cache file not found at {cache_file}")
988
+ logger.debug(f"Map stitcher cache file not found: {cache_file}")
989
+ except Exception as e:
990
+ import traceback
991
+ print(f"🗺️ SERVER: Error loading portal connections: {e}")
992
+ print(f"🗺️ SERVER: Full traceback: {traceback.format_exc()}")
993
+ logger.debug(f"Could not load portal connections from persistent storage: {e}")
994
+
995
+ # The battle information already contains all necessary data
996
+ # No additional analysis needed - keep it clean
997
+
998
+ # Remove MapStitcher instance to avoid serialization issues
999
+ # The instance is only for internal use by state_formatter
1000
+ if "_map_stitcher_instance" in state.get("map", {}):
1001
+ del state["map"]["_map_stitcher_instance"]
1002
+
1003
+ # Convert screenshot to base64 if available
1004
+ if state["visual"]["screenshot"]:
1005
+ buffer = io.BytesIO()
1006
+ state["visual"]["screenshot"].save(buffer, format='PNG')
1007
+ img_str = base64.b64encode(buffer.getvalue()).decode()
1008
+ state["visual"]["screenshot_base64"] = img_str
1009
+ # Remove the PIL image object to avoid serialization issues
1010
+ del state["visual"]["screenshot"]
1011
+
1012
+ with step_lock:
1013
+ current_step = step_count
1014
+
1015
+ # Include action queue info for multiprocess coordination
1016
+ queue_length = len(action_queue) # Action queue access is atomic for len()
1017
+
1018
+ return ComprehensiveStateResponse(
1019
+ visual=state["visual"],
1020
+ player=state["player"],
1021
+ game=state["game"],
1022
+ map=state["map"],
1023
+ milestones=state.get("milestones", {}),
1024
+ location_connections=state.get("location_connections", {}),
1025
+ step_number=current_step,
1026
+ status="running",
1027
+ action_queue_length=queue_length
1028
+ )
1029
+
1030
+ except Exception as e:
1031
+ logger.error(f"Error getting comprehensive state: {e}")
1032
+ raise HTTPException(status_code=500, detail=str(e))
1033
+
1034
+ @app.get("/debug/memory")
1035
+ async def debug_memory():
1036
+ """Debug memory reading (basic version)"""
1037
+ if env is None:
1038
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
1039
+
1040
+ try:
1041
+ if not env.memory_reader:
1042
+ return {"error": "Memory reader not initialized"}
1043
+
1044
+ # Test basic memory access
1045
+ diagnostics = env.memory_reader.test_memory_access()
1046
+
1047
+ # Try to read some basic data
1048
+ try:
1049
+ party_size = env.memory_reader.read_party_size()
1050
+ coordinates = env.memory_reader.read_coordinates()
1051
+ money = env.memory_reader.read_money()
1052
+
1053
+ # Add new debugging info
1054
+ is_in_battle = env.memory_reader.is_in_battle()
1055
+ game_state = env.memory_reader.get_game_state()
1056
+ player_name = env.memory_reader.read_player_name()
1057
+
1058
+ # Add battle detection debugging
1059
+ try:
1060
+ battle_addr = env.memory_reader.IN_BATTLE_BIT_ADDR
1061
+ battle_raw_value = env.memory_reader._read_u8(battle_addr)
1062
+ battle_mask = env.memory_reader.IN_BATTLE_BITMASK
1063
+ battle_result = (battle_raw_value & battle_mask) != 0
1064
+ except Exception as e:
1065
+ battle_raw_value = None
1066
+ battle_mask = None
1067
+ battle_result = None
1068
+
1069
+ diagnostics.update({
1070
+ 'party_size': party_size,
1071
+ 'coordinates': coordinates,
1072
+ 'money': money,
1073
+ 'is_in_battle': is_in_battle,
1074
+ 'game_state': game_state,
1075
+ 'player_name': player_name,
1076
+ 'battle_detection': {
1077
+ 'address': f'0x{battle_addr:08x}' if 'battle_addr' in locals() else 'unknown',
1078
+ 'raw_value': f'0x{battle_raw_value:02x}' if battle_raw_value is not None else 'error',
1079
+ 'mask': f'0x{battle_mask:02x}' if battle_mask is not None else 'unknown',
1080
+ 'result': battle_result
1081
+ },
1082
+ 'working_reads': True
1083
+ })
1084
+ except Exception as read_error:
1085
+ diagnostics['read_error'] = str(read_error)
1086
+ diagnostics['working_reads'] = False
1087
+
1088
+ return diagnostics
1089
+
1090
+ except Exception as e:
1091
+ logger.error(f"Error debugging memory: {e}")
1092
+ return {"error": str(e)}
1093
+
1094
+ @app.get("/debug/memory/comprehensive")
1095
+ async def debug_memory_comprehensive():
1096
+ """Comprehensive memory reading test with detailed diagnostics"""
1097
+ if env is None:
1098
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
1099
+
1100
+ try:
1101
+ # Use the comprehensive memory testing method
1102
+ test_results = env.test_memory_reading()
1103
+ return test_results
1104
+
1105
+ except Exception as e:
1106
+ logger.error(f"Error running comprehensive memory test: {e}")
1107
+ return {"error": str(e)}
1108
+
1109
+ @app.get("/debug/memory/dump")
1110
+ async def debug_memory_dump(start: int = 0x02000000, length: int = 0x1000):
1111
+ """Dump raw memory from the emulator"""
1112
+ if env is None:
1113
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
1114
+
1115
+ try:
1116
+ if not env.memory_reader:
1117
+ return {"error": "Memory reader not initialized"}
1118
+
1119
+ # Read raw memory bytes
1120
+ memory_bytes = env.memory_reader._read_bytes(start, length)
1121
+
1122
+ # Convert to hex string for easy viewing
1123
+ hex_data = memory_bytes.hex()
1124
+
1125
+ # Also try to decode as text using Pokemon Emerald character mapping
1126
+ try:
1127
+ from pokemon_env.emerald_utils import EmeraldCharmap
1128
+ charmap = EmeraldCharmap()
1129
+ decoded_text = charmap.decode(memory_bytes)
1130
+ except:
1131
+ decoded_text = "Could not decode as text"
1132
+
1133
+ return {
1134
+ "start_address": f"0x{start:08X}",
1135
+ "length": length,
1136
+ "hex_data": hex_data,
1137
+ "decoded_text": decoded_text,
1138
+ "raw_bytes": [b for b in memory_bytes[:100]] # First 100 bytes as numbers
1139
+ }
1140
+
1141
+ except Exception as e:
1142
+ logger.error(f"Error dumping memory: {e}")
1143
+ return {"error": str(e)}
1144
+
1145
+
1146
+
1147
+ @app.get("/test_stream")
1148
+ async def test_stream():
1149
+ """Simple test stream to verify SSE works"""
1150
+ from fastapi.responses import StreamingResponse
1151
+ import asyncio
1152
+
1153
+ async def simple_stream():
1154
+ for i in range(5):
1155
+ yield f"data: {{'test': {i}, 'timestamp': {time.time()}}}\n\n"
1156
+ await asyncio.sleep(1)
1157
+ yield f"data: {{'done': true}}\n\n"
1158
+
1159
+ return StreamingResponse(simple_stream(), media_type="text/event-stream")
1160
+
1161
+ @app.get("/agent_stream")
1162
+ async def stream_agent_thinking():
1163
+ """Stream agent thinking in real-time using Server-Sent Events"""
1164
+ from fastapi.responses import StreamingResponse
1165
+ import asyncio
1166
+
1167
+ async def event_stream():
1168
+ """Generate server-sent events for agent thinking"""
1169
+ logger.info("SSE: Starting event stream")
1170
+ last_timestamp = "" # Track last seen timestamp instead of count
1171
+ sent_timestamps = set() # Track all sent timestamps to avoid duplicates
1172
+ heartbeat_counter = 0
1173
+
1174
+ try:
1175
+ # Send initial connection message
1176
+ yield f"data: {json.dumps({'status': 'connected', 'timestamp': time.time()})}\n\n"
1177
+
1178
+ # On startup, mark all existing interactions as "sent" to avoid flooding with old messages
1179
+ # We only want to stream NEW interactions from this point forward
1180
+ try:
1181
+ log_files = sorted(glob.glob("llm_logs/llm_log_*.jsonl"))
1182
+ for log_file in log_files:
1183
+ if os.path.exists(log_file):
1184
+ with open(log_file, 'r', encoding='utf-8') as f:
1185
+ for line in f:
1186
+ try:
1187
+ entry = json.loads(line.strip())
1188
+ if entry.get("type") == "interaction":
1189
+ timestamp = entry.get("timestamp", "")
1190
+ if timestamp:
1191
+ sent_timestamps.add(timestamp)
1192
+ except:
1193
+ continue
1194
+ logger.info(f"SSE: Marked {len(sent_timestamps)} existing interactions as already sent")
1195
+ except Exception as init_e:
1196
+ logger.warning(f"SSE: Error initializing sent timestamps: {init_e}")
1197
+
1198
+ while True:
1199
+ try:
1200
+ heartbeat_counter += 1
1201
+
1202
+ # Use simple file reading instead of complex get_agent_thinking()
1203
+ current_step = 0
1204
+
1205
+ with step_lock:
1206
+ current_step = agent_step_count
1207
+
1208
+ new_interactions = []
1209
+ try:
1210
+ # Read LLM log files directly (same as working /agent endpoint)
1211
+ log_files = sorted(glob.glob("llm_logs/llm_log_*.jsonl"))
1212
+
1213
+ # Check all recent log files for new entries
1214
+ for log_file in log_files[-2:]: # Check last 2 files to catch session changes
1215
+ if os.path.exists(log_file):
1216
+ with open(log_file, 'r', encoding='utf-8') as f:
1217
+ lines = f.readlines()
1218
+ # Check all lines, not just last 5
1219
+ for line in lines:
1220
+ try:
1221
+ entry = json.loads(line.strip())
1222
+ if entry.get("type") == "interaction":
1223
+ timestamp = entry.get("timestamp", "")
1224
+ # Only add if we haven't sent this timestamp before
1225
+ if timestamp and timestamp not in sent_timestamps:
1226
+ new_interactions.append({
1227
+ "type": entry.get("interaction_type", "unknown"),
1228
+ "response": entry.get("response", ""),
1229
+ "duration": entry.get("duration", 0),
1230
+ "timestamp": timestamp
1231
+ })
1232
+ except:
1233
+ continue
1234
+ except Exception as file_e:
1235
+ logger.warning(f"SSE: File reading error: {file_e}")
1236
+
1237
+ # Sort by timestamp to ensure chronological order
1238
+ new_interactions.sort(key=lambda x: x.get("timestamp", ""))
1239
+
1240
+ # Check if there are new interactions
1241
+ if new_interactions:
1242
+ logger.info(f"SSE: Found {len(new_interactions)} new interactions to send")
1243
+ # Send new interactions
1244
+ for interaction in new_interactions:
1245
+
1246
+ event_data = {
1247
+ "step": current_step,
1248
+ "type": interaction.get("type", "unknown"),
1249
+ "response": interaction.get("response", ""),
1250
+ "duration": interaction.get("duration", 0),
1251
+ "timestamp": interaction.get("timestamp", ""),
1252
+ "is_new": True
1253
+ }
1254
+
1255
+ yield f"data: {json.dumps(event_data)}\n\n"
1256
+ # Mark this timestamp as sent
1257
+ sent_timestamps.add(interaction.get("timestamp", ""))
1258
+
1259
+ # Send periodic heartbeat to keep connection alive (every 10 cycles = 5 seconds)
1260
+ elif heartbeat_counter % 10 == 0:
1261
+ yield f"data: {json.dumps({'heartbeat': True, 'timestamp': time.time(), 'step': current_step})}\n\n"
1262
+
1263
+ # Wait before checking again
1264
+ await asyncio.sleep(0.5)
1265
+
1266
+ except Exception as e:
1267
+ logger.error(f"SSE: Error in stream loop: {e}")
1268
+ yield f"data: {json.dumps({'error': str(e), 'timestamp': time.time()})}\n\n"
1269
+ await asyncio.sleep(2)
1270
+
1271
+ except Exception as outer_e:
1272
+ logger.error(f"SSE: Fatal error in event stream: {outer_e}")
1273
+ yield f"data: {json.dumps({'fatal_error': str(outer_e), 'timestamp': time.time()})}\n\n"
1274
+
1275
+ return StreamingResponse(event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"})
1276
+
1277
+ @app.get("/agent")
1278
+ async def get_agent_thinking():
1279
+ """Get current agent thinking status and recent LLM interactions"""
1280
+ try:
1281
+ # Get the most recent LLM log file
1282
+ from utils.llm_logger import get_llm_logger
1283
+
1284
+ # Get recent LLM interactions
1285
+ llm_logger = get_llm_logger()
1286
+ session_summary = llm_logger.get_session_summary()
1287
+
1288
+ # Find all LLM log files and get interactions from all of them
1289
+ import glob
1290
+ log_files = glob.glob("llm_logs/llm_log_*.jsonl")
1291
+ logger.info(f"Found {len(log_files)} log files: {log_files}")
1292
+
1293
+ # Get recent interactions from all log files
1294
+ recent_interactions = []
1295
+ for log_file in log_files:
1296
+ if os.path.exists(log_file):
1297
+ try:
1298
+ with open(log_file, 'r', encoding='utf-8') as f:
1299
+ lines = f.readlines()
1300
+ # Get interactions from this file
1301
+ for line in lines:
1302
+ try:
1303
+ entry = json.loads(line.strip())
1304
+ if entry.get("type") == "interaction":
1305
+ recent_interactions.append({
1306
+ "type": entry.get("interaction_type", "unknown"),
1307
+ "prompt": entry.get("prompt", ""),
1308
+ "response": entry.get("response", ""),
1309
+ "duration": entry.get("duration", 0),
1310
+ "timestamp": entry.get("timestamp", "")
1311
+ })
1312
+ except json.JSONDecodeError:
1313
+ continue
1314
+ except Exception as e:
1315
+ logger.error(f"Error reading LLM log {log_file}: {e}")
1316
+
1317
+ # Sort by timestamp and keep only the most recent interaction (current step)
1318
+ recent_interactions.sort(key=lambda x: x.get("timestamp", ""))
1319
+ recent_interactions = recent_interactions[-1:] if recent_interactions else []
1320
+ logger.info(f"Found {len(recent_interactions)} recent interactions (showing current step only)")
1321
+
1322
+ # Format the agent thinking display
1323
+ if recent_interactions:
1324
+ interaction = recent_interactions[-1] # Get the most recent interaction
1325
+ current_thought = f"Current step LLM output:\n"
1326
+ current_thought += f"{interaction['type'].upper()} ({interaction['duration']:.2f}s)\n"
1327
+ current_thought += f"Response: {interaction['response']}"
1328
+ else:
1329
+ current_thought = "No recent LLM interactions. Agent is ready to process game state."
1330
+
1331
+ with step_lock:
1332
+ current_step = agent_step_count # Use agent step count instead of frame step count
1333
+
1334
+ return {
1335
+ "status": "thinking",
1336
+ "current_thought": current_thought,
1337
+ "confidence": 0.85,
1338
+ "timestamp": time.time(),
1339
+ "llm_session": session_summary,
1340
+ "recent_interactions": recent_interactions,
1341
+ "current_step": current_step
1342
+ }
1343
+
1344
+ except Exception as e:
1345
+ logger.error(f"Error in agent thinking: {e}")
1346
+ return {
1347
+ "status": "error",
1348
+ "current_thought": f"Error getting agent thinking: {str(e)}",
1349
+ "confidence": 0.0,
1350
+ "timestamp": time.time()
1351
+ }
1352
+
1353
+ @app.get("/metrics")
1354
+ async def get_metrics():
1355
+ """Get cumulative metrics for the run"""
1356
+ global latest_metrics
1357
+
1358
+ try:
1359
+ # Return the latest metrics received from client (with thread safety)
1360
+ with step_lock:
1361
+ metrics = latest_metrics.copy()
1362
+ metrics["agent_step_count"] = agent_step_count
1363
+
1364
+ # If metrics haven't been initialized by client yet, try to load from checkpoint
1365
+ # BUT only if checkpoint loading is enabled (not for fresh starts with --load-state)
1366
+ if metrics.get("total_llm_calls", 0) == 0 and checkpoint_loading_enabled:
1367
+ # Check cache folder first, then fall back to old location
1368
+ cache_dir = ".pokeagent_cache"
1369
+ checkpoint_file = os.path.join(cache_dir, "checkpoint_llm.txt") if os.path.exists(cache_dir) else "checkpoint_llm.txt"
1370
+ if not os.path.exists(checkpoint_file) and os.path.exists("checkpoint_llm.txt"):
1371
+ checkpoint_file = "checkpoint_llm.txt"
1372
+ if os.path.exists(checkpoint_file):
1373
+ try:
1374
+ with open(checkpoint_file, 'r', encoding='utf-8') as f:
1375
+ checkpoint_data = json.load(f)
1376
+ if "cumulative_metrics" in checkpoint_data:
1377
+ checkpoint_metrics = checkpoint_data["cumulative_metrics"]
1378
+ metrics.update(checkpoint_metrics)
1379
+
1380
+ # Recalculate total_run_time based on original start_time
1381
+ if "start_time" in checkpoint_metrics:
1382
+ metrics["total_run_time"] = time.time() - checkpoint_metrics["start_time"]
1383
+
1384
+ # Update agent step count from checkpoint
1385
+ if "agent_step_count" in checkpoint_data:
1386
+ metrics["agent_step_count"] = checkpoint_data["agent_step_count"]
1387
+ except:
1388
+ pass
1389
+
1390
+ return metrics
1391
+
1392
+ except Exception as e:
1393
+ logger.error(f"Error getting metrics: {e}")
1394
+ return {
1395
+ "total_tokens": 0,
1396
+ "prompt_tokens": 0,
1397
+ "completion_tokens": 0,
1398
+ "total_cost": 0.0,
1399
+ "total_actions": 0,
1400
+ "total_run_time": 0,
1401
+ "total_llm_calls": 0,
1402
+ "agent_step_count": agent_step_count
1403
+ }
1404
+
1405
+ # Store latest metrics from client
1406
+ latest_metrics = {
1407
+ "total_tokens": 0,
1408
+ "prompt_tokens": 0,
1409
+ "completion_tokens": 0,
1410
+ "total_cost": 0.0,
1411
+ "total_actions": 0,
1412
+ "total_run_time": 0,
1413
+ "total_llm_calls": 0,
1414
+ "start_time": time.time() # Will be overwritten if checkpoint is loaded
1415
+ }
1416
+
1417
+ # Flag to track whether checkpoint loading should be enabled
1418
+ checkpoint_loading_enabled = True # Will be set based on startup args
1419
+
1420
+ @app.post("/reset_metrics")
1421
+ async def reset_metrics():
1422
+ """Reset all metrics to zero for fresh start"""
1423
+ global latest_metrics, agent_step_count, checkpoint_loading_enabled
1424
+
1425
+ with step_lock:
1426
+ latest_metrics.update({
1427
+ "total_tokens": 0,
1428
+ "prompt_tokens": 0,
1429
+ "completion_tokens": 0,
1430
+ "total_cost": 0.0,
1431
+ "total_actions": 0,
1432
+ "total_run_time": 0,
1433
+ "total_llm_calls": 0,
1434
+ "start_time": time.time()
1435
+ })
1436
+ agent_step_count = 0
1437
+ # Disable checkpoint loading to prevent loading from checkpoint_llm.txt
1438
+ checkpoint_loading_enabled = False
1439
+
1440
+ print("🔄 Server metrics reset for fresh start - checkpoint loading disabled")
1441
+ return {"status": "reset", "timestamp": time.time()}
1442
+
1443
+ @app.post("/agent_step")
1444
+ async def update_agent_step(request: Request = None):
1445
+ """Update the agent step count and metrics (called by agent.py)"""
1446
+ global agent_step_count, latest_metrics
1447
+
1448
+ try:
1449
+ # Check if this is a direct set operation or has metrics
1450
+ if request:
1451
+ try:
1452
+ request_data = await request.json()
1453
+
1454
+ # Update metrics if provided (with thread safety)
1455
+ if "metrics" in request_data and isinstance(request_data["metrics"], dict):
1456
+ with step_lock: # Use existing lock for thread safety
1457
+ # Safely update each metric individually to avoid race conditions
1458
+ for key, value in request_data["metrics"].items():
1459
+ if key in latest_metrics:
1460
+ # Always protect total_actions as it's managed by server
1461
+ if key == "total_actions":
1462
+ continue
1463
+ else:
1464
+ latest_metrics[key] = value
1465
+
1466
+ # Handle set_step for initialization
1467
+ if "set_step" in request_data:
1468
+ with step_lock:
1469
+ agent_step_count = request_data["set_step"]
1470
+ return {"status": "set", "agent_step": agent_step_count}
1471
+ except Exception as e:
1472
+ logger.error(f"Error processing agent_step request: {e}")
1473
+ # Continue with default increment behavior
1474
+ except Exception as e:
1475
+ logger.error(f"Error in agent_step endpoint: {e}")
1476
+ # Continue with default increment behavior
1477
+
1478
+ # Default increment behavior
1479
+ with step_lock:
1480
+ agent_step_count += 1
1481
+
1482
+ return {"status": "updated", "agent_step": agent_step_count}
1483
+
1484
+ @app.get("/llm_logs")
1485
+ async def get_llm_logs():
1486
+ """Get recent LLM log entries"""
1487
+ try:
1488
+ from utils.llm_logger import get_llm_logger
1489
+
1490
+ llm_logger = get_llm_logger()
1491
+ session_summary = llm_logger.get_session_summary()
1492
+
1493
+ # Get recent log entries
1494
+ recent_entries = []
1495
+ if os.path.exists(llm_logger.log_file):
1496
+ try:
1497
+ with open(llm_logger.log_file, 'r', encoding='utf-8') as f:
1498
+ lines = f.readlines()
1499
+ # Get the last 20 entries
1500
+ for line in lines[-20:]:
1501
+ try:
1502
+ entry = json.loads(line.strip())
1503
+ recent_entries.append(entry)
1504
+ except json.JSONDecodeError:
1505
+ continue
1506
+ except Exception as e:
1507
+ logger.error(f"Error reading LLM log: {e}")
1508
+
1509
+ return {
1510
+ "session_summary": session_summary,
1511
+ "recent_entries": recent_entries,
1512
+ "log_file": llm_logger.log_file
1513
+ }
1514
+
1515
+ except Exception as e:
1516
+ logger.error(f"Error getting LLM logs: {e}")
1517
+ return {"error": str(e)}
1518
+
1519
+ # Milestone checking is now handled by the emulator
1520
+
1521
+ @app.get("/milestones")
1522
+ async def get_milestones():
1523
+ """Get current milestones achieved based on persistent tracking"""
1524
+ if env is None:
1525
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
1526
+
1527
+ try:
1528
+ # Get milestones directly from emulator
1529
+ return env.get_milestones()
1530
+
1531
+ except Exception as e:
1532
+ logger.error(f"Error getting milestones: {e}")
1533
+ # Fallback to basic milestones if memory reading fails
1534
+ basic_milestones = [
1535
+ {"id": 1, "name": "GAME_STARTED", "category": "basic", "completed": True, "timestamp": time.time()},
1536
+ {"id": 2, "name": "EMULATOR_RUNNING", "category": "basic", "completed": True, "timestamp": time.time()},
1537
+ ]
1538
+ return {
1539
+ "milestones": basic_milestones,
1540
+ "completed": 2,
1541
+ "total": 2,
1542
+ "progress": 1.0,
1543
+ "tracking_system": "fallback",
1544
+ "error": str(e)
1545
+ }
1546
+
1547
+ # Global list to track recent button presses
1548
+ recent_button_presses = []
1549
+
1550
+ @app.get("/recent_actions")
1551
+ async def get_recent_actions():
1552
+ """Get recently pressed buttons"""
1553
+ global recent_button_presses
1554
+ return {
1555
+ "recent_buttons": recent_button_presses[-10:], # Last 10 button presses
1556
+ "timestamp": time.time()
1557
+ }
1558
+
1559
+ @app.get("/debug/milestones")
1560
+ async def debug_milestones():
1561
+ """Debug milestone tracking system"""
1562
+ if env is None:
1563
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
1564
+
1565
+ try:
1566
+ # Get current working directory and list milestone files
1567
+ import glob
1568
+ current_dir = os.getcwd()
1569
+ milestone_files = glob.glob("*milestones*.json")
1570
+
1571
+ # Check if default milestone file exists and get its info
1572
+ default_file_info = None
1573
+ if os.path.exists(env.milestone_tracker.filename):
1574
+ try:
1575
+ with open(env.milestone_tracker.filename, 'r') as f:
1576
+ default_data = json.load(f)
1577
+ default_file_info = {
1578
+ "exists": True,
1579
+ "size": os.path.getsize(env.milestone_tracker.filename),
1580
+ "last_modified": time.ctime(os.path.getmtime(env.milestone_tracker.filename)),
1581
+ "milestone_count": len(default_data.get('milestones', {})),
1582
+ "last_updated": default_data.get('last_updated', 'unknown')
1583
+ }
1584
+ except Exception as e:
1585
+ default_file_info = {"exists": True, "error": str(e)}
1586
+ else:
1587
+ default_file_info = {"exists": False}
1588
+
1589
+ return {
1590
+ "tracking_system": "file_based",
1591
+ "current_filename": env.milestone_tracker.filename,
1592
+ "current_milestones": len(env.milestone_tracker.milestones),
1593
+ "completed_milestones": sum(1 for m in env.milestone_tracker.milestones.values() if m.get("completed", False)),
1594
+ "default_file_info": default_file_info,
1595
+ "milestone_files_in_directory": milestone_files,
1596
+ "working_directory": current_dir,
1597
+ "milestone_details": env.milestone_tracker.milestones
1598
+ }
1599
+ except Exception as e:
1600
+ logger.error(f"Error in milestone debug: {e}")
1601
+ return {"error": str(e)}
1602
+
1603
+ @app.post("/debug/reset_milestones")
1604
+ async def reset_milestones():
1605
+ """Reset all milestones (for testing)"""
1606
+ if env is None:
1607
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
1608
+
1609
+ try:
1610
+ env.milestone_tracker.reset_all()
1611
+ return {
1612
+ "status": "reset",
1613
+ "milestone_file": env.milestone_tracker.filename,
1614
+ "remaining_milestones": len(env.milestone_tracker.milestones)
1615
+ }
1616
+ except Exception as e:
1617
+ logger.error(f"Error resetting milestones: {e}")
1618
+ return {"error": str(e)}
1619
+
1620
+ @app.post("/debug/test_milestone_operations")
1621
+ async def test_milestone_operations():
1622
+ """Test milestone loading and saving operations"""
1623
+ if env is None:
1624
+ raise HTTPException(status_code=400, detail="Emulator not initialized")
1625
+
1626
+ try:
1627
+ # Test data
1628
+ test_milestones = {
1629
+ "TEST_MILESTONE_1": {
1630
+ "completed": True,
1631
+ "timestamp": time.time(),
1632
+ "first_completed": time.time()
1633
+ },
1634
+ "TEST_MILESTONE_2": {
1635
+ "completed": False,
1636
+ "timestamp": None
1637
+ }
1638
+ }
1639
+
1640
+ # Save current state
1641
+ original_milestones = env.milestone_tracker.milestones.copy()
1642
+ original_filename = env.milestone_tracker.filename
1643
+
1644
+ # Test 1: Save milestones with state filename
1645
+ test_state_filename = "test_state_123.sav"
1646
+ env.milestone_tracker.milestones = test_milestones.copy()
1647
+ saved_filename = env.milestone_tracker.save_milestones_for_state(test_state_filename)
1648
+
1649
+ # Test 2: Load milestones for state
1650
+ env.milestone_tracker.milestones = {} # Clear current milestones
1651
+ env.milestone_tracker.load_milestones_for_state(test_state_filename)
1652
+ loaded_milestones = env.milestone_tracker.milestones.copy()
1653
+
1654
+ # Test 3: Check if file was created
1655
+ file_exists = os.path.exists(saved_filename)
1656
+ file_size = os.path.getsize(saved_filename) if file_exists else 0
1657
+
1658
+ # Restore original state
1659
+ env.milestone_tracker.milestones = original_milestones
1660
+ env.milestone_tracker.filename = original_filename
1661
+
1662
+ return {
1663
+ "test_results": {
1664
+ "save_operation": {
1665
+ "filename": saved_filename,
1666
+ "file_exists": file_exists,
1667
+ "file_size": file_size,
1668
+ "milestones_saved": len(test_milestones)
1669
+ },
1670
+ "load_operation": {
1671
+ "milestones_loaded": len(loaded_milestones),
1672
+ "milestones_match": loaded_milestones == test_milestones,
1673
+ "loaded_milestones": loaded_milestones
1674
+ }
1675
+ },
1676
+ "original_state_restored": True
1677
+ }
1678
+
1679
+ except Exception as e:
1680
+ logger.error(f"Error testing milestone operations: {e}")
1681
+ return {"error": str(e)}
1682
+
1683
+ @app.post("/stop")
1684
+ async def stop_server():
1685
+ """Stop the server"""
1686
+ global running
1687
+ running = False
1688
+ return {"status": "stopping"}
1689
+
1690
+ @app.post("/save_state")
1691
+ async def save_state_endpoint(request: dict):
1692
+ """Save the current emulator state to a file"""
1693
+ try:
1694
+ os.makedirs(".pokeagent_cache", exist_ok=True)
1695
+ filepath = request.get("filepath", ".pokeagent_cache/manual_save.state")
1696
+ if env:
1697
+ env.save_state(filepath)
1698
+ logger.info(f"💾 State saved to: {filepath}")
1699
+ return {"status": "success", "message": f"State saved to {filepath}"}
1700
+ else:
1701
+ return JSONResponse(status_code=500, content={"error": "Emulator not initialized"})
1702
+ except Exception as e:
1703
+ logger.error(f"Error saving state: {e}")
1704
+ return JSONResponse(status_code=500, content={"error": str(e)})
1705
+
1706
+ @app.post("/load_state")
1707
+ async def load_state_endpoint(request: dict):
1708
+ """Load an emulator state from a file"""
1709
+ try:
1710
+ os.makedirs(".pokeagent_cache", exist_ok=True)
1711
+ filepath = request.get("filepath", ".pokeagent_cache/manual_save.state")
1712
+ if env:
1713
+ if not os.path.exists(filepath):
1714
+ return JSONResponse(status_code=404, content={"error": f"State file not found: {filepath}"})
1715
+ env.load_state(filepath)
1716
+ logger.info(f"📂 State loaded from: {filepath}")
1717
+ return {"status": "success", "message": f"State loaded from {filepath}"}
1718
+ else:
1719
+ return JSONResponse(status_code=500, content={"error": "Emulator not initialized"})
1720
+ except Exception as e:
1721
+ logger.error(f"Error loading state: {e}")
1722
+ return JSONResponse(status_code=500, content={"error": str(e)})
1723
+
1724
+ @app.post("/checkpoint")
1725
+ async def save_checkpoint(request_data: dict = None):
1726
+ """Save checkpoint - called by client when step count reaches checkpoint interval"""
1727
+ try:
1728
+ step_count = request_data.get("step_count", 0) if request_data else 0
1729
+
1730
+ # Save emulator state
1731
+ os.makedirs(".pokeagent_cache", exist_ok=True)
1732
+ checkpoint_state = ".pokeagent_cache/checkpoint.state"
1733
+ if env:
1734
+ env.save_state(checkpoint_state)
1735
+ logger.info(f"💾 Server: Saved checkpoint state at step {step_count}")
1736
+
1737
+ # Save milestones
1738
+ if env.milestone_tracker:
1739
+ milestone_file = env.milestone_tracker.save_milestones_for_state(checkpoint_state)
1740
+ logger.info(f"💾 Server: Saved checkpoint milestones")
1741
+
1742
+ return {
1743
+ "status": "checkpoint_saved",
1744
+ "step_count": step_count,
1745
+ "files": {
1746
+ "state": checkpoint_state,
1747
+ "milestones": f".pokeagent_cache/checkpoint_milestones.json",
1748
+ "map": f".pokeagent_cache/checkpoint_grids.json"
1749
+ }
1750
+ }
1751
+ else:
1752
+ return {"status": "error", "message": "No emulator available"}
1753
+
1754
+ except Exception as e:
1755
+ logger.error(f"Failed to save checkpoint: {e}")
1756
+ return {"status": "error", "message": str(e)}
1757
+
1758
+ @app.post("/sync_llm_metrics")
1759
+ async def sync_llm_metrics(request: Request):
1760
+ """Sync LLM cumulative metrics from client to server"""
1761
+ try:
1762
+ request_data = await request.json()
1763
+ cumulative_metrics = request_data.get("cumulative_metrics", {})
1764
+
1765
+ if not cumulative_metrics:
1766
+ return {"status": "error", "message": "No metrics provided"}, 400
1767
+
1768
+ # Update server's LLM logger with client's cumulative metrics
1769
+ from utils.llm_logger import get_llm_logger
1770
+ llm_logger = get_llm_logger()
1771
+ if llm_logger is not None:
1772
+ # Update cumulative metrics (but preserve server-managed metrics like start_time and total_actions)
1773
+ server_start_time = llm_logger.cumulative_metrics.get("start_time")
1774
+ server_total_actions = llm_logger.cumulative_metrics.get("total_actions")
1775
+
1776
+ llm_logger.cumulative_metrics.update(cumulative_metrics)
1777
+
1778
+ # Restore server-managed metrics
1779
+ if server_start_time:
1780
+ llm_logger.cumulative_metrics["start_time"] = server_start_time
1781
+ if server_total_actions is not None:
1782
+ llm_logger.cumulative_metrics["total_actions"] = server_total_actions
1783
+
1784
+ # Also sync to latest_metrics for stream.html display (excluding server-managed metrics)
1785
+ global latest_metrics
1786
+ with step_lock:
1787
+ for key, value in cumulative_metrics.items():
1788
+ if key in latest_metrics and key not in ["total_actions", "start_time"]:
1789
+ latest_metrics[key] = value
1790
+
1791
+ logger.info(f"🔄 Synced LLM metrics: {cumulative_metrics.get('total_llm_calls', 0)} calls, {cumulative_metrics.get('total_tokens', 0)} tokens, ${cumulative_metrics.get('total_cost', 0):.6f}")
1792
+ return {"status": "metrics_synced"}
1793
+ else:
1794
+ logger.error("No LLM logger available for sync")
1795
+ return {"status": "error", "message": "No LLM logger available"}, 500
1796
+ except Exception as e:
1797
+ logger.error(f"Error syncing LLM metrics: {e}")
1798
+ return {"status": "error", "message": str(e)}, 500
1799
+
1800
+ @app.post("/save_agent_history")
1801
+ async def save_agent_history():
1802
+ """Save agent history to checkpoint_llm.txt (called by client after each step)"""
1803
+ try:
1804
+ # Use server-side LLM logger to save checkpoint
1805
+ from utils.llm_logger import get_llm_logger
1806
+
1807
+ llm_logger = get_llm_logger()
1808
+ if llm_logger is not None:
1809
+ # Save checkpoint using current agent step count
1810
+ global agent_step_count
1811
+ # Save to cache folder (llm_logger handles path internally now)
1812
+ llm_logger.save_checkpoint(agent_step_count=agent_step_count)
1813
+ logger.info(f"💾 Saved LLM checkpoint at step {agent_step_count}")
1814
+ return {"status": "agent_history_saved", "step_count": agent_step_count}
1815
+ else:
1816
+ return {"status": "no_logger", "message": "No LLM logger available"}
1817
+
1818
+ except Exception as e:
1819
+ logger.error(f"Failed to save agent history: {e}")
1820
+ return {"status": "error", "message": str(e)}
1821
+
1822
+ @app.post("/load_checkpoint")
1823
+ async def load_checkpoint():
1824
+ """Load checkpoint state - called by client on startup if --load-checkpoint flag is used"""
1825
+ try:
1826
+ checkpoint_state = ".pokeagent_cache/checkpoint.state"
1827
+
1828
+ if not os.path.exists(checkpoint_state):
1829
+ return {"status": "no_checkpoint", "message": "No .pokeagent_cache/checkpoint.state file found"}
1830
+
1831
+ if env:
1832
+ env.load_state(checkpoint_state)
1833
+ logger.info(f"📂 Server: Loaded checkpoint state")
1834
+
1835
+ # Load milestones if available
1836
+ if env.milestone_tracker:
1837
+ try:
1838
+ env.milestone_tracker.load_milestones_for_state(checkpoint_state)
1839
+ logger.info(f"📂 Server: Loaded checkpoint milestones")
1840
+ except:
1841
+ logger.warning(f"Could not load checkpoint milestones")
1842
+
1843
+ return {
1844
+ "status": "checkpoint_loaded",
1845
+ "files": {
1846
+ "state": checkpoint_state,
1847
+ "milestones": f".pokeagent_cache/checkpoint_milestones.json",
1848
+ "map": f".pokeagent_cache/checkpoint_grids.json"
1849
+ }
1850
+ }
1851
+ else:
1852
+ return {"status": "error", "message": "No emulator available"}
1853
+
1854
+ except Exception as e:
1855
+ logger.error(f"Failed to load checkpoint: {e}")
1856
+ return {"status": "error", "message": str(e)}
1857
+
1858
+ def main():
1859
+ """Main function"""
1860
+ import argparse
1861
+
1862
+ global state_update_running, state_update_thread
1863
+
1864
+ # Set up signal handlers
1865
+ signal.signal(signal.SIGINT, signal_handler)
1866
+ signal.signal(signal.SIGTERM, signal_handler)
1867
+
1868
+ parser = argparse.ArgumentParser(description="Simple Pokemon Emerald Server")
1869
+ parser.add_argument("--port", type=int, default=8000, help="Port for FastAPI server")
1870
+ parser.add_argument("--manual", action="store_true", help="Enable manual mode with keyboard input and overlay")
1871
+ parser.add_argument("--load-state", type=str, help="Load a saved state file on startup")
1872
+ parser.add_argument("--record", action="store_true", help="Record video of the gameplay")
1873
+ parser.add_argument("--no-ocr", action="store_true", help="Disable OCR dialogue detection")
1874
+ # Server always runs headless - display handled by client
1875
+
1876
+ args = parser.parse_args()
1877
+
1878
+ # Check for environment variables from multiprocess mode
1879
+ env_load_state = os.environ.get("LOAD_STATE")
1880
+ if env_load_state and not args.load_state:
1881
+ args.load_state = env_load_state
1882
+ print(f"📂 Using load state from environment: {env_load_state}")
1883
+ if env_load_state == ".pokeagent_cache/checkpoint.state":
1884
+ if os.path.exists(".pokeagent_cache/checkpoint.state"):
1885
+ print(f"✅ Server startup: .pokeagent_cache/checkpoint.state file exists")
1886
+ else:
1887
+ print(f"❌ Server startup: .pokeagent_cache/checkpoint.state file MISSING!")
1888
+
1889
+ # Set checkpoint loading flag based on whether this is a true checkpoint load
1890
+ global checkpoint_loading_enabled
1891
+ env_load_checkpoint_mode = os.environ.get("LOAD_CHECKPOINT_MODE")
1892
+
1893
+ if env_load_checkpoint_mode == "true":
1894
+ checkpoint_loading_enabled = True
1895
+ print("🔄 Checkpoint loading enabled - will restore LLM metrics from checkpoint_llm.txt")
1896
+
1897
+ # Initialize LLM logger and load checkpoint immediately during server startup
1898
+ from utils.llm_logger import get_llm_logger
1899
+ llm_logger = get_llm_logger()
1900
+ # Check both cache folder and old location
1901
+ cache_dir = ".pokeagent_cache"
1902
+ checkpoint_file = os.path.join(cache_dir, "checkpoint_llm.txt") if os.path.exists(cache_dir) else "checkpoint_llm.txt"
1903
+ if not os.path.exists(checkpoint_file) and os.path.exists("checkpoint_llm.txt"):
1904
+ checkpoint_file = "checkpoint_llm.txt"
1905
+
1906
+ if llm_logger and os.path.exists(checkpoint_file):
1907
+ restored_step_count = llm_logger.load_checkpoint(checkpoint_file)
1908
+ if restored_step_count is not None:
1909
+ global agent_step_count
1910
+ agent_step_count = restored_step_count
1911
+ print(f"✅ Server startup: restored LLM checkpoint with step count {restored_step_count}")
1912
+
1913
+ # Sync latest_metrics with loaded cumulative metrics
1914
+ global latest_metrics
1915
+ latest_metrics.update(llm_logger.cumulative_metrics)
1916
+ print(f"✅ Server startup: synced metrics - actions: {latest_metrics.get('total_actions', 0)}, cost: {latest_metrics.get('total_cost', 0)}")
1917
+ else:
1918
+ print("❌ Server startup: failed to load LLM checkpoint")
1919
+ else:
1920
+ print("ℹ️ Server startup: no checkpoint_llm.txt file found")
1921
+ elif env_load_checkpoint_mode == "false":
1922
+ checkpoint_loading_enabled = False
1923
+ print("✨ Fresh start mode - will NOT load LLM metrics from checkpoint_llm.txt")
1924
+ else:
1925
+ # Default behavior: allow checkpoint loading unless explicitly disabled
1926
+ checkpoint_loading_enabled = True
1927
+ print("🔄 Checkpoint loading enabled by default - will restore LLM metrics from checkpoint_llm.txt if available")
1928
+
1929
+ print("Starting Fixed Simple Pokemon Emerald Server")
1930
+ # Initialize video recording if requested
1931
+ init_video_recording(args.record)
1932
+ print("Server mode - headless operation, display handled by client")
1933
+ if args.no_ocr:
1934
+ print("OCR dialogue detection disabled")
1935
+ print("Press Ctrl+C to stop")
1936
+
1937
+ # Initialize emulator
1938
+ # Skip initial state reading if we're going to load a state
1939
+ if not setup_environment(skip_initial_state=(args.load_state is not None)):
1940
+ print("Failed to initialize emulator")
1941
+ return
1942
+
1943
+ # Disable dialogue detection if --no-ocr flag is set
1944
+ if args.no_ocr:
1945
+ if env and env.memory_reader:
1946
+ env.memory_reader._dialog_detection_enabled = False
1947
+ print("🚫 All dialogue detection disabled (--no-ocr flag)")
1948
+
1949
+ # Load state if specified
1950
+ if args.load_state:
1951
+ try:
1952
+ env.load_state(args.load_state)
1953
+ print(f"Loaded state from: {args.load_state}")
1954
+
1955
+ # Milestones and map data are automatically loaded by env.load_state()
1956
+ # Check what was loaded
1957
+ state_dir = os.path.dirname(args.load_state)
1958
+ base_name = os.path.splitext(os.path.basename(args.load_state))[0]
1959
+
1960
+ milestone_file = os.path.join(state_dir, f"{base_name}_milestones.json")
1961
+ if os.path.exists(milestone_file):
1962
+ print(f"📂 Loaded milestones from: {milestone_file}")
1963
+
1964
+ grids_file = os.path.join(state_dir, f"{base_name}_grids.json")
1965
+ if os.path.exists(grids_file):
1966
+ print(f"🗺️ Loaded map grids from: {grids_file}")
1967
+
1968
+ # Map buffer should already be found by emulator.load_state()
1969
+ if env.memory_reader and env.memory_reader._map_buffer_addr:
1970
+ print(f"Map buffer already initialized at 0x{env.memory_reader._map_buffer_addr:08X}")
1971
+
1972
+ # Now log the initial GAME_RUNNING milestone after state is loaded
1973
+ try:
1974
+ env.milestone_tracker.mark_completed("GAME_RUNNING")
1975
+ initial_state = env.get_comprehensive_state()
1976
+
1977
+ import hashlib
1978
+ state_str = str(initial_state)
1979
+ state_hash = hashlib.md5(state_str.encode()).hexdigest()[:8]
1980
+
1981
+ anticheat_tracker.log_submission_data(
1982
+ step=0,
1983
+ state_data=initial_state,
1984
+ action_taken="INIT",
1985
+ decision_time=0.0,
1986
+ state_hash=state_hash,
1987
+ manual_mode=True,
1988
+ milestone_override="GAME_RUNNING"
1989
+ )
1990
+ print("Initial GAME_RUNNING milestone logged after state load")
1991
+
1992
+ # Trigger a map stitcher update to ensure visual map is ready
1993
+ try:
1994
+ if env.memory_reader and env.memory_reader._map_stitcher:
1995
+ # Check if map stitcher is empty and collect initial map data if needed
1996
+ map_areas = env.memory_reader._map_stitcher.map_areas
1997
+ if not map_areas:
1998
+ print("🗺️ Map stitcher is empty, collecting initial map data...")
1999
+ # Collect initial map data
2000
+ tiles = env.memory_reader.read_map_around_player(radius=7)
2001
+ if tiles:
2002
+ print(f"🗺️ Collected {len(tiles)} tiles, updating map stitcher")
2003
+ # Create minimal state for stitcher update
2004
+ initial_state = {"map": {}}
2005
+ env.memory_reader._update_map_stitcher(tiles, initial_state)
2006
+ print("✅ Initial map data collection completed")
2007
+ else:
2008
+ print("❌ Could not collect initial map data")
2009
+
2010
+ # Get current state for map stitcher update
2011
+ current_state = env.get_comprehensive_state()
2012
+ # The map stitcher should now have data
2013
+ # This just ensures the visual_map is generated
2014
+ print("Ensuring map stitcher visual data is ready after state load")
2015
+ except Exception as e:
2016
+ print(f"Note: Could not update map stitcher after state load: {e}")
2017
+ except Exception as e:
2018
+ print(f"Warning: Could not log initial milestone: {e}")
2019
+ except Exception as e:
2020
+ print(f"Failed to load state from {args.load_state}: {e}")
2021
+ print("Continuing with fresh game state...")
2022
+
2023
+ # Start lightweight milestone updater thread
2024
+ state_update_running = True
2025
+ state_update_thread = threading.Thread(target=periodic_milestone_updater, daemon=True)
2026
+ state_update_thread.start()
2027
+
2028
+ # Start FastAPI server in background thread
2029
+ server_thread = threading.Thread(target=run_fastapi_server, args=(args.port,), daemon=True)
2030
+ server_thread.start()
2031
+
2032
+ # Get local IP for network access
2033
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
2034
+ from utils.get_local_ip import get_local_ip
2035
+ local_ip = get_local_ip()
2036
+
2037
+ print(f"🌐 FastAPI server running:")
2038
+ print(f" Local: http://localhost:{args.port}")
2039
+ print(f" Network: http://{local_ip}:{args.port}")
2040
+ print(f"📺 Stream interface: http://{local_ip}:{args.port}/stream")
2041
+ print("Available endpoints:")
2042
+ print(" /status - Server status")
2043
+ print(" /screenshot - Current screenshot")
2044
+ print(" /action - Take action (POST)")
2045
+ print(" /state - Comprehensive game state (visual + memory data)")
2046
+ print(" /agent - Agent thinking status")
2047
+ print(" /milestones - Current milestones achieved")
2048
+ print(" /recent_actions - Recently pressed buttons")
2049
+ print(" /debug/memory - Debug memory reading (basic)")
2050
+ print(" /debug/memory/comprehensive - Comprehensive memory diagnostics")
2051
+ print(" /debug/milestones - Debug milestone tracking system")
2052
+ print(" /debug/reset_milestones - Reset all milestones (POST)")
2053
+ print(" /debug/test_milestone_operations - Test milestone save/load (POST)")
2054
+ print(" /stop - Stop server")
2055
+
2056
+ try:
2057
+ # Run headless game loop in main thread
2058
+ game_loop(manual_mode=False) # Server always runs in server mode
2059
+ except KeyboardInterrupt:
2060
+ print("Interrupted by user")
2061
+ except Exception as e:
2062
+ print(f"Error: {e}")
2063
+ finally:
2064
+ # Cleanup
2065
+ global running
2066
+ running = False
2067
+ state_update_running = False
2068
+ if env:
2069
+ env.stop()
2070
+ print("Server stopped")
2071
+
2072
+ # Initialize emulator when imported for multiprocess mode
2073
+ def init_for_multiprocess():
2074
+ """Initialize emulator when server is imported for multiprocess mode"""
2075
+ global env
2076
+
2077
+ if env is None: # Only initialize once
2078
+ # Check for environment variables set by agent.py multiprocess mode
2079
+ rom_path = os.environ.get("ROM_PATH", "Emerald-GBAdvance/rom.gba")
2080
+ load_state = os.environ.get("LOAD_STATE")
2081
+ record_video = os.environ.get("RECORD_VIDEO") == "1"
2082
+ no_ocr = os.environ.get("NO_OCR") == "1"
2083
+
2084
+ print(f"🔧 Initializing server for multiprocess mode...")
2085
+ print(f" ROM: {rom_path}")
2086
+ if load_state:
2087
+ print(f" Load state: {load_state}")
2088
+
2089
+ # Initialize emulator
2090
+ try:
2091
+ if not os.path.exists(rom_path):
2092
+ raise RuntimeError(f"ROM not found at {rom_path}")
2093
+
2094
+ env = EmeraldEmulator(rom_path=rom_path)
2095
+ env.initialize()
2096
+
2097
+ # Initialize video recording if requested
2098
+ init_video_recording(record_video)
2099
+
2100
+ # Disable OCR if requested
2101
+ if no_ocr and env and env.memory_reader:
2102
+ env.memory_reader._dialog_detection_enabled = False
2103
+ print("🚫 All dialogue detection disabled (--no-ocr flag)")
2104
+
2105
+ # Load state if specified
2106
+ if load_state:
2107
+ try:
2108
+ print(f"🔄 Attempting to load state from: {load_state}")
2109
+ env.load_state(load_state)
2110
+ print(f"📂 Successfully loaded state from: {load_state}")
2111
+
2112
+ # Milestones and map data are automatically loaded by env.load_state()
2113
+ # Check what was loaded
2114
+ state_dir = os.path.dirname(load_state)
2115
+ base_name = os.path.splitext(os.path.basename(load_state))[0]
2116
+
2117
+ milestone_file = os.path.join(state_dir, f"{base_name}_milestones.json")
2118
+ if os.path.exists(milestone_file):
2119
+ print(f"📋 Loaded milestones from: {milestone_file}")
2120
+
2121
+ grids_file = os.path.join(state_dir, f"{base_name}_grids.json")
2122
+ if os.path.exists(grids_file):
2123
+ print(f"🗺️ Loaded map grids from: {grids_file}")
2124
+
2125
+ # Map buffer should already be found by emulator.load_state()
2126
+ if env.memory_reader and env.memory_reader._map_buffer_addr:
2127
+ print(f"📍 Map buffer initialized at 0x{env.memory_reader._map_buffer_addr:08X}")
2128
+
2129
+ print(f"✅ State loading complete for {load_state}")
2130
+
2131
+ except Exception as e:
2132
+ print(f"❌ Failed to load state from {load_state}: {e}")
2133
+ print(" Continuing with fresh game state...")
2134
+
2135
+ # Start lightweight milestone updater thread
2136
+ global state_update_running, state_update_thread
2137
+ state_update_running = True
2138
+ state_update_thread = threading.Thread(target=periodic_milestone_updater, daemon=True)
2139
+ state_update_thread.start()
2140
+
2141
+ print("✅ Server initialized successfully for multiprocess mode")
2142
+
2143
+ except Exception as e:
2144
+ print(f"❌ Failed to initialize server for multiprocess mode: {e}")
2145
+ raise
2146
+
2147
+ # Auto-initialize when imported for multiprocess mode (when ROM_PATH env var is set)
2148
+ if os.environ.get("ROM_PATH") and __name__ != "__main__":
2149
+ init_for_multiprocess()
2150
+
2151
+ if __name__ == "__main__":
2152
+ main()