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,1481 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ State Formatter Utility
4
+
5
+ Converts comprehensive game state objects to formatted text for LLM prompts and debugging.
6
+ Centralizes all state formatting logic for consistency across agent modules.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import numpy as np
12
+ from PIL import Image
13
+ from utils.map_formatter import format_map_grid, format_map_for_llm, generate_dynamic_legend, format_tile_to_symbol
14
+ import base64
15
+ import io
16
+ import os, sys
17
+ from pokemon_env.enums import MetatileBehavior
18
+ from utils import state_formatter as sf
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Global location tracking - MapStitcher handles all persistent storage
23
+ CURRENT_LOCATION = None
24
+ LAST_LOCATION = None
25
+ LAST_TRANSITION = None # Stores transition coordinates
26
+ MAP_STITCHER_SAVE_CALLBACK = None # Callback to save map stitcher when location connections change
27
+ MAP_STITCHER_INSTANCE = None # Reference to the MapStitcher instance
28
+
29
+ def _get_location_connections_from_cache():
30
+ """Read location connections from MapStitcher's cache file"""
31
+ try:
32
+ cache_file = '.pokeagent_cache/map_stitcher_data.json'
33
+ if os.path.exists(cache_file):
34
+ with open(cache_file, 'r') as f:
35
+ data = json.load(f)
36
+ return data.get('location_connections', {})
37
+ except Exception as e:
38
+ print(f"Failed to read location connections from cache: {e}")
39
+ return {}
40
+
41
+ def detect_dialogue_on_frame(screenshot_base64=None, frame_array=None):
42
+ """
43
+ Detect if dialogue is visible on the game frame by analyzing the lower portion.
44
+
45
+ Args:
46
+ screenshot_base64: Base64 encoded screenshot string
47
+ frame_array: numpy array of the frame (240x160 for GBA)
48
+
49
+ Returns:
50
+ dict: {
51
+ 'has_dialogue': bool,
52
+ 'confidence': float (0-1),
53
+ 'reason': str
54
+ }
55
+ """
56
+ try:
57
+ # Convert base64 to image if needed
58
+ if screenshot_base64 and not frame_array:
59
+ image_data = base64.b64decode(screenshot_base64)
60
+ image = Image.open(io.BytesIO(image_data))
61
+ frame_array = np.array(image)
62
+
63
+ if frame_array is None:
64
+ return {'has_dialogue': False, 'confidence': 0.0, 'reason': 'No frame data'}
65
+
66
+ # GBA resolution is 240x160
67
+ height, width = frame_array.shape[:2]
68
+
69
+ # Dialogue typically appears in the bottom 40-50 pixels
70
+ dialogue_region = frame_array[height-50:, :] # Bottom 50 pixels
71
+
72
+ # Convert to grayscale for analysis
73
+ if len(dialogue_region.shape) == 3:
74
+ # Convert RGB to grayscale
75
+ gray = np.dot(dialogue_region[...,:3], [0.299, 0.587, 0.114]).astype(np.uint8)
76
+ else:
77
+ gray = dialogue_region
78
+
79
+ # Dialogue boxes in Pokemon are typically:
80
+ # 1. Have a distinct blue/white color scheme
81
+ # 2. Have high contrast text on background
82
+ # 3. Have consistent borders
83
+
84
+ # Check for dialogue box characteristics
85
+ # 1. Check for blue dialogue box (typical color range)
86
+ if len(dialogue_region.shape) == 3:
87
+ # Blue dialogue box detection (Pokemon dialogue boxes are often blue-ish)
88
+ blue_mask = (
89
+ (dialogue_region[:,:,2] > 100) & # High blue channel
90
+ (dialogue_region[:,:,2] > dialogue_region[:,:,0] * 1.2) & # More blue than red
91
+ (dialogue_region[:,:,2] > dialogue_region[:,:,1] * 1.2) # More blue than green
92
+ )
93
+ blue_percentage = np.sum(blue_mask) / blue_mask.size
94
+
95
+ # White/light regions (text areas)
96
+ white_mask = (
97
+ (dialogue_region[:,:,0] > 200) &
98
+ (dialogue_region[:,:,1] > 200) &
99
+ (dialogue_region[:,:,2] > 200)
100
+ )
101
+ white_percentage = np.sum(white_mask) / white_mask.size
102
+ else:
103
+ blue_percentage = 0
104
+ white_percentage = 0
105
+
106
+ # 2. Check for high contrast (text on background)
107
+ std_dev = np.std(gray)
108
+
109
+ # 3. Check for horizontal lines (dialogue box borders)
110
+ # Detect horizontal edges
111
+ vertical_diff = np.abs(np.diff(gray, axis=0))
112
+ horizontal_edges = np.sum(vertical_diff > 50) / vertical_diff.size
113
+
114
+ # 4. Check for consistent patterns (not random pixels)
115
+ # Calculate local variance to detect structured content
116
+ local_variance = []
117
+ for i in range(0, gray.shape[0]-5, 5):
118
+ for j in range(0, gray.shape[1]-5, 5):
119
+ patch = gray[i:i+5, j:j+5]
120
+ local_variance.append(np.var(patch))
121
+
122
+ avg_local_variance = np.mean(local_variance) if local_variance else 0
123
+
124
+ # Scoring system
125
+ confidence = 0.0
126
+ reasons = []
127
+
128
+ # Blue/white dialogue box detection
129
+ if blue_percentage > 0.3:
130
+ confidence += 0.3
131
+ reasons.append("blue dialogue box detected")
132
+
133
+ if white_percentage > 0.1 and white_percentage < 0.5:
134
+ confidence += 0.2
135
+ reasons.append("text area detected")
136
+
137
+ # High contrast for text
138
+ if std_dev > 30 and std_dev < 100:
139
+ confidence += 0.2
140
+ reasons.append("text contrast detected")
141
+
142
+ # Horizontal edges (box borders)
143
+ if horizontal_edges > 0.01 and horizontal_edges < 0.1:
144
+ confidence += 0.2
145
+ reasons.append("dialogue box borders detected")
146
+
147
+ # Structured content (not random)
148
+ if avg_local_variance > 100 and avg_local_variance < 2000:
149
+ confidence += 0.1
150
+ reasons.append("structured content")
151
+
152
+ # Determine if dialogue is present
153
+ has_dialogue = confidence >= 0.5
154
+
155
+ return {
156
+ 'has_dialogue': has_dialogue,
157
+ 'confidence': min(confidence, 1.0),
158
+ 'reason': ', '.join(reasons) if reasons else 'no dialogue indicators'
159
+ }
160
+
161
+ except Exception as e:
162
+ logger.warning(f"Failed to detect dialogue on frame: {e}")
163
+ return {'has_dialogue': False, 'confidence': 0.0, 'reason': f'error: {e}'}
164
+
165
+ def format_state(state_data, format_type="summary", include_debug_info=False, include_npcs=True):
166
+ """
167
+ Format comprehensive state data into readable text.
168
+
169
+ Args:
170
+ state_data (dict): The comprehensive state from /state endpoint
171
+ format_type (str): "summary" for one-line summary, "detailed" for multi-line LLM format
172
+ include_debug_info (bool): Whether to include extra debug information (for detailed format)
173
+ include_npcs (bool): Whether to include NPC information in the state
174
+
175
+ Returns:
176
+ str: Formatted state text
177
+ """
178
+ if format_type == "summary":
179
+ return _format_state_summary(state_data)
180
+ elif format_type == "detailed":
181
+ return _format_state_detailed(state_data, include_debug_info, include_npcs)
182
+ else:
183
+ raise ValueError(f"Unknown format_type: {format_type}. Use 'summary' or 'detailed'")
184
+
185
+ def format_state_for_llm(state_data, include_debug_info=False, include_npcs=True):
186
+ """
187
+ Format comprehensive state data into a readable context for the VLM.
188
+
189
+ Args:
190
+ state_data (dict): The comprehensive state from /state endpoint
191
+ include_debug_info (bool): Whether to include extra debug information
192
+ include_npcs (bool): Whether to include NPC information in the state
193
+
194
+ Returns:
195
+ str: Formatted state context for LLM prompts
196
+ """
197
+ return format_state(state_data, format_type="detailed", include_debug_info=include_debug_info, include_npcs=include_npcs)
198
+
199
+ def format_state_summary(state_data):
200
+ """
201
+ Create a concise one-line summary of the current state for logging.
202
+
203
+ Args:
204
+ state_data (dict): The comprehensive state from /state endpoint
205
+
206
+ Returns:
207
+ str: Concise state summary
208
+ """
209
+ return format_state(state_data, format_type="summary")
210
+
211
+ def _format_state_summary(state_data):
212
+ """
213
+ Internal function to create a concise one-line summary of the current state.
214
+ """
215
+ player_data = state_data.get('player', {})
216
+ game_data = state_data.get('game', {})
217
+
218
+ summary_parts = []
219
+
220
+ # Player name (don't show during title sequence)
221
+ player_location = player_data.get('location', '')
222
+ if player_data.get('name') and player_location != 'TITLE_SEQUENCE':
223
+ summary_parts.append(f"Player: {player_data['name']}")
224
+
225
+ # Location
226
+ location = player_data.get('location')
227
+ if location:
228
+ summary_parts.append(f"Location: {location}")
229
+
230
+ # Position
231
+ position = player_data.get('position')
232
+ if position and isinstance(position, dict):
233
+ summary_parts.append(f"Pos: ({position.get('x', '?')}, {position.get('y', '?')})")
234
+
235
+ # Facing direction - removed as it's often unreliable
236
+ # facing = player_data.get('facing')
237
+ # if facing:
238
+ # summary_parts.append(f"Facing: {facing}")
239
+
240
+ # Game state
241
+ game_state = game_data.get('game_state')
242
+ if game_state:
243
+ summary_parts.append(f"State: {game_state}")
244
+
245
+ # Battle status
246
+ if game_data.get('is_in_battle'):
247
+ summary_parts.append("In Battle")
248
+
249
+ # Money
250
+ money = game_data.get('money')
251
+ if money is not None:
252
+ summary_parts.append(f"Money: ${money}")
253
+
254
+ # Party information
255
+ party_data = player_data.get('party')
256
+ if party_data:
257
+ party_size = len(party_data)
258
+ if party_size > 0:
259
+ # Get first Pokemon details
260
+ first_pokemon = party_data[0]
261
+ species = first_pokemon.get('species_name', 'Unknown')
262
+ level = first_pokemon.get('level', '?')
263
+ hp = first_pokemon.get('current_hp', '?')
264
+ max_hp = first_pokemon.get('max_hp', '?')
265
+ status = first_pokemon.get('status', 'OK')
266
+
267
+ summary_parts.append(f"Party: {party_size} pokemon")
268
+ summary_parts.append(f"Lead: {species} Lv{level} HP:{hp}/{max_hp} {status}")
269
+
270
+ # Pokedex information
271
+ pokedex_seen = game_data.get('pokedex_seen')
272
+ pokedex_caught = game_data.get('pokedex_caught')
273
+ if pokedex_seen is not None:
274
+ summary_parts.append(f"Pokedex: {pokedex_caught or 0} caught, {pokedex_seen} seen")
275
+
276
+ # Badges
277
+ badges = game_data.get('badges')
278
+ if badges:
279
+ if isinstance(badges, list):
280
+ badge_count = len(badges)
281
+ else:
282
+ badge_count = badges
283
+ summary_parts.append(f"Badges: {badge_count}")
284
+
285
+ # Items
286
+ item_count = game_data.get('item_count')
287
+ if item_count is not None:
288
+ summary_parts.append(f"Items: {item_count}")
289
+
290
+ # Game time
291
+ time_data = game_data.get('time')
292
+ if time_data and isinstance(time_data, (list, tuple)) and len(time_data) >= 3:
293
+ hours, minutes, seconds = time_data[:3]
294
+ summary_parts.append(f"Time: {hours:02d}:{minutes:02d}:{seconds:02d}")
295
+
296
+ # Dialog text (if any)
297
+ dialog_text = game_data.get('dialog_text')
298
+ dialogue_detected = game_data.get('dialogue_detected', {})
299
+ if dialog_text and dialogue_detected.get('has_dialogue', True):
300
+ # Only show dialogue if frame detection confirms it (or if detection wasn't run)
301
+ # Truncate dialog text to first 50 characters
302
+ dialog_preview = dialog_text[:50].replace('\n', ' ').strip()
303
+ if len(dialog_text) > 50:
304
+ dialog_preview += "..."
305
+ summary_parts.append(f"Dialog: {dialog_preview}")
306
+
307
+ # Progress context (if available)
308
+ progress_context = game_data.get('progress_context')
309
+ if progress_context:
310
+ badges_obtained = progress_context.get('badges_obtained', 0)
311
+ visited_locations = progress_context.get('visited_locations', [])
312
+ if badges_obtained > 0:
313
+ summary_parts.append(f"Progress: {badges_obtained} badges, {len(visited_locations)} locations")
314
+
315
+ return " | ".join(summary_parts) if summary_parts else "No state data"
316
+
317
+ def _format_state_detailed(state_data, include_debug_info=False, include_npcs=True):
318
+ """
319
+ Internal function to create detailed multi-line state format for LLM prompts.
320
+ """
321
+ context_parts = []
322
+
323
+ # Check both player and game sections for data
324
+ player_data = state_data.get('player', {})
325
+ game_data = state_data.get('game', {})
326
+
327
+ # Check if we're in battle to determine formatting mode
328
+ is_in_battle = game_data.get('is_in_battle', False) or game_data.get('in_battle', False)
329
+
330
+ if is_in_battle:
331
+ # BATTLE MODE: Focus on battle-relevant information
332
+ context_parts.append("=== BATTLE MODE ===")
333
+ context_parts.append("Currently in battle - map and dialogue information hidden")
334
+
335
+ # Battle information first
336
+ if 'battle_info' in game_data and game_data['battle_info']:
337
+ battle = game_data['battle_info']
338
+ context_parts.append("\n=== BATTLE STATUS ===")
339
+
340
+ # Battle type and context
341
+ battle_type = battle.get('battle_type', 'unknown')
342
+ context_parts.append(f"Battle Type: {battle_type.title()}")
343
+ if battle.get('is_capturable'):
344
+ context_parts.append("🟢 Wild Pokémon - CAN BE CAPTURED")
345
+ if battle.get('can_escape'):
346
+ context_parts.append("🟡 Can escape from battle")
347
+
348
+ # Player's active Pokémon
349
+ if 'player_pokemon' in battle and battle['player_pokemon']:
350
+ player_pkmn = battle['player_pokemon']
351
+ context_parts.append(f"\n--- YOUR POKÉMON ---")
352
+ context_parts.append(f"{player_pkmn.get('nickname', player_pkmn.get('species', 'Unknown'))} (Lv.{player_pkmn.get('level', '?')})")
353
+
354
+ # Health display with percentage
355
+ current_hp = player_pkmn.get('current_hp', 0)
356
+ max_hp = player_pkmn.get('max_hp', 1)
357
+ hp_pct = player_pkmn.get('hp_percentage', 0)
358
+ health_bar = "🟢" if hp_pct > 50 else "🟡" if hp_pct > 25 else "🔴"
359
+ context_parts.append(f" HP: {current_hp}/{max_hp} ({hp_pct}%) {health_bar}")
360
+
361
+ # Status condition
362
+ status = player_pkmn.get('status', 'Normal')
363
+ if status != 'Normal':
364
+ context_parts.append(f" Status: {status}")
365
+
366
+ # Types
367
+ types = player_pkmn.get('types', [])
368
+ if types:
369
+ context_parts.append(f" Type: {'/'.join(types)}")
370
+
371
+ # Available moves with PP
372
+ moves = player_pkmn.get('moves', [])
373
+ move_pp = player_pkmn.get('move_pp', [])
374
+ if moves:
375
+ context_parts.append(f" Moves:")
376
+ for i, move in enumerate(moves):
377
+ if move and move.strip():
378
+ pp = move_pp[i] if i < len(move_pp) else '?'
379
+ context_parts.append(f" {i+1}. {move} (PP: {pp})")
380
+
381
+ # Opponent Pokémon
382
+ if 'opponent_pokemon' in battle:
383
+ if battle['opponent_pokemon']:
384
+ opp_pkmn = battle['opponent_pokemon']
385
+ context_parts.append(f"\n--- OPPONENT POKÉMON ---")
386
+ context_parts.append(f"{opp_pkmn.get('species', 'Unknown')} (Lv.{opp_pkmn.get('level', '?')})")
387
+
388
+ # Health display with percentage
389
+ current_hp = opp_pkmn.get('current_hp', 0)
390
+ max_hp = opp_pkmn.get('max_hp', 1)
391
+ hp_pct = opp_pkmn.get('hp_percentage', 0)
392
+ health_bar = "🟢" if hp_pct > 50 else "🟡" if hp_pct > 25 else "🔴"
393
+ context_parts.append(f" HP: {current_hp}/{max_hp} ({hp_pct}%) {health_bar}")
394
+
395
+ # Status condition
396
+ status = opp_pkmn.get('status', 'Normal')
397
+ if status != 'Normal':
398
+ context_parts.append(f" Status: {status}")
399
+
400
+ # Types
401
+ types = opp_pkmn.get('types', [])
402
+ if types:
403
+ context_parts.append(f" Type: {'/'.join(types)}")
404
+
405
+ # Moves (for wild Pokémon, showing moves can help with strategy)
406
+ moves = opp_pkmn.get('moves', [])
407
+ if moves and any(move.strip() for move in moves):
408
+ context_parts.append(f" Known Moves:")
409
+ for i, move in enumerate(moves):
410
+ if move and move.strip():
411
+ context_parts.append(f" • {move}")
412
+
413
+ # Stats (helpful for battle strategy)
414
+ stats = opp_pkmn.get('stats', {})
415
+ if stats:
416
+ context_parts.append(f" Battle Stats: ATK:{stats.get('attack', '?')} DEF:{stats.get('defense', '?')} SPD:{stats.get('speed', '?')}")
417
+
418
+ # Special indicators
419
+ if opp_pkmn.get('is_shiny'):
420
+ context_parts.append(f" ✨ SHINY POKÉMON!")
421
+ else:
422
+ # Opponent data not ready
423
+ context_parts.append(f"\n--- OPPONENT POKÉMON ---")
424
+ opponent_status = battle.get('opponent_status', 'Opponent data not available')
425
+ context_parts.append(f"⏳ {opponent_status}")
426
+ context_parts.append(" (Battle may be in initialization phase)")
427
+
428
+ # Battle interface info
429
+ interface = battle.get('battle_interface', {})
430
+ available_actions = interface.get('available_actions', [])
431
+ if available_actions:
432
+ context_parts.append(f"\n--- AVAILABLE ACTIONS ---")
433
+ context_parts.append(f"Options: {', '.join(available_actions)}")
434
+
435
+ # Trainer battle specific info
436
+ if battle.get('is_trainer_battle'):
437
+ remaining = battle.get('opponent_team_remaining', 1)
438
+ if remaining > 1:
439
+ context_parts.append(f"\nTrainer has {remaining} Pokémon remaining")
440
+
441
+ # Battle phase info
442
+ battle_phase = battle.get('battle_phase_name')
443
+ if battle_phase:
444
+ context_parts.append(f"\nBattle Phase: {battle_phase}")
445
+
446
+ # Party information (important for switching decisions)
447
+ context_parts.append("\n=== PARTY STATUS ===")
448
+ party_context = _format_party_info(player_data, game_data)
449
+ context_parts.extend(party_context)
450
+
451
+ # Trainer info if available
452
+ if 'name' in player_data and player_data['name']:
453
+ context_parts.append(f"\nTrainer: {player_data['name']}")
454
+
455
+ # Money/badges might be relevant
456
+ money = player_data.get('money') or game_data.get('money')
457
+ if money is not None:
458
+ context_parts.append(f"Money: ${money}")
459
+
460
+ else:
461
+ # NORMAL MODE: Full state information
462
+ context_parts.append("=== PLAYER INFO ===")
463
+
464
+ # Player name and basic info (don't show during title sequence as it hasn't been set yet)
465
+ player_location = player_data.get('location', '')
466
+ if 'name' in player_data and player_data['name'] and player_location != 'TITLE_SEQUENCE':
467
+ context_parts.append(f"Player Name: {player_data['name']}")
468
+
469
+ # Position information
470
+ position = _get_player_position(player_data)
471
+ if position:
472
+ context_parts.append(f"Position: X={position.get('x', 'unknown')}, Y={position.get('y', 'unknown')}")
473
+
474
+ # Facing direction - removed as it's often unreliable
475
+ # if 'facing' in player_data and player_data['facing']:
476
+ # context_parts.append(f"Facing: {player_data['facing']}")
477
+
478
+ # Money (check both player and game sections)
479
+ money = player_data.get('money') or game_data.get('money')
480
+ if money is not None:
481
+ context_parts.append(f"Money: ${money}")
482
+
483
+ # Pokemon Party (check both player and game sections)
484
+ party_context = _format_party_info(player_data, game_data)
485
+ context_parts.extend(party_context)
486
+
487
+ # Map/Location information with traversability (NOT shown in battle)
488
+ map_context = _format_map_info(state_data.get('map', {}), player_data, include_debug_info, include_npcs, state_data)
489
+ context_parts.extend(map_context)
490
+
491
+ # Game state information (including dialogue if not in battle)
492
+ game_context = _format_game_state(game_data, state_data)
493
+ context_parts.extend(game_context)
494
+
495
+ # Debug information if requested (shown in both modes)
496
+ if include_debug_info:
497
+ debug_context = _format_debug_info(state_data)
498
+ context_parts.extend(debug_context)
499
+
500
+ return "\n".join(context_parts)
501
+
502
+ def format_state_for_debug(state_data):
503
+ """
504
+ Format state data for detailed debugging output.
505
+
506
+ Args:
507
+ state_data (dict): The comprehensive state from /state endpoint
508
+
509
+ Returns:
510
+ str: Detailed debug information
511
+ """
512
+ debug_parts = []
513
+ debug_parts.append("=" * 60)
514
+ debug_parts.append("COMPREHENSIVE STATE DEBUG")
515
+ debug_parts.append("=" * 60)
516
+
517
+ # Raw structure overview
518
+ debug_parts.append("\n--- STRUCTURE OVERVIEW ---")
519
+ for key, value in state_data.items():
520
+ if isinstance(value, dict):
521
+ debug_parts.append(f"{key}: dict with {len(value)} keys")
522
+ elif isinstance(value, list):
523
+ debug_parts.append(f"{key}: list with {len(value)} items")
524
+ else:
525
+ debug_parts.append(f"{key}: {type(value).__name__} = {value}")
526
+
527
+ # Detailed formatted state
528
+ debug_parts.append("\n--- FORMATTED STATE ---")
529
+ debug_parts.append(format_state_for_llm(state_data, include_debug_info=True))
530
+
531
+ # Raw JSON (truncated if too long)
532
+ debug_parts.append("\n--- RAW JSON (truncated) ---")
533
+ raw_json = json.dumps(state_data, indent=2)
534
+ if len(raw_json) > 2000:
535
+ debug_parts.append(raw_json[:2000] + "\n... (truncated)")
536
+ else:
537
+ debug_parts.append(raw_json)
538
+
539
+ debug_parts.append("=" * 60)
540
+ return "\n".join(debug_parts)
541
+
542
+ # Helper functions for state formatting
543
+
544
+ def _get_player_position(player_data):
545
+ """Extract player position from various possible locations in player data."""
546
+ if 'coordinates' in player_data:
547
+ return player_data['coordinates']
548
+ elif 'position' in player_data:
549
+ pos = player_data['position']
550
+ # print( _get_player_position found position: {pos}, type: {type(pos)}")
551
+ # Check if it's a valid position dict with x,y keys
552
+ if pos and isinstance(pos, dict) and 'x' in pos and 'y' in pos:
553
+ return pos
554
+ # print( position invalid - missing x,y or not dict")
555
+ return None
556
+
557
+ def _get_party_size(party_data):
558
+ """Get party size from party data regardless of format."""
559
+ if isinstance(party_data, dict):
560
+ return party_data.get('size', len(party_data.get('pokemon', [])))
561
+ elif isinstance(party_data, list):
562
+ return len(party_data)
563
+ return 0
564
+
565
+ def _format_party_info(player_data, game_data):
566
+ """Format pokemon party information."""
567
+ context_parts = []
568
+
569
+ # Pokemon Party (check both player and game sections)
570
+ party_data = player_data.get('party') or game_data.get('party')
571
+ if party_data:
572
+ pokemon_list = []
573
+ if isinstance(party_data, dict) and party_data.get('pokemon'):
574
+ # Format: {"size": X, "pokemon": [...]}
575
+ pokemon_list = party_data.get('pokemon', [])
576
+ party_size = party_data.get('size', len(pokemon_list))
577
+ elif isinstance(party_data, list):
578
+ # Format: [pokemon1, pokemon2, ...]
579
+ pokemon_list = party_data
580
+ party_size = len(pokemon_list)
581
+ else:
582
+ party_size = 0
583
+
584
+ if party_size > 0:
585
+ context_parts.append(f"Pokemon Party ({party_size} pokemon):")
586
+ for i, pokemon in enumerate(pokemon_list[:6]):
587
+ if pokemon:
588
+ species = pokemon.get('species_name', pokemon.get('species', 'Unknown'))
589
+ level = pokemon.get('level', '?')
590
+ hp = pokemon.get('current_hp', '?')
591
+ max_hp = pokemon.get('max_hp', '?')
592
+ status = pokemon.get('status', 'Normal')
593
+ context_parts.append(f" {i+1}. {species} (Lv.{level}) HP: {hp}/{max_hp} Status: {status}")
594
+ else:
595
+ context_parts.append("No Pokemon in party")
596
+ else:
597
+ context_parts.append("No Pokemon in party")
598
+
599
+ return context_parts
600
+
601
+ def _format_map_info(map_info, player_data=None, include_debug_info=False, include_npcs=True, full_state_data=None):
602
+ """Format map and traversability information using MapStitcher."""
603
+ context_parts = []
604
+
605
+ if not map_info:
606
+ return context_parts
607
+
608
+ # Get location name from player data
609
+ location_name = None
610
+ if player_data and 'location' in player_data and player_data['location']:
611
+ location = player_data['location']
612
+ if isinstance(location, dict):
613
+ location_name = location.get('map_name', str(location))
614
+ else:
615
+ location_name = str(location)
616
+
617
+ # Special handling for title sequence - don't show map
618
+ if location_name == 'TITLE_SEQUENCE':
619
+ context_parts.append("\n=== LOCATION INFO ===")
620
+ context_parts.append(f"Current Location: {location_name}")
621
+ context_parts.append("No map available during title sequence")
622
+ return context_parts
623
+
624
+ context_parts.append("\n=== LOCATION & MAP INFO ===")
625
+ if location_name:
626
+ context_parts.append(f"Current Location: {location_name}")
627
+
628
+ # Also add current map if different
629
+ if 'current_map' in map_info and map_info['current_map'] != location_name:
630
+ context_parts.append(f"Current Map: {map_info['current_map']}")
631
+
632
+ # Get player coordinates
633
+ player_coords = None
634
+ player_coords_dict = map_info.get('player_coords', {})
635
+ if isinstance(player_coords_dict, dict) and player_coords_dict.get('x') is not None:
636
+ player_coords = (player_coords_dict.get('x', 0), player_coords_dict.get('y', 0))
637
+ elif player_coords_dict and not isinstance(player_coords_dict, dict):
638
+ player_coords = player_coords_dict
639
+ elif player_data and 'position' in player_data:
640
+ pos = player_data['position']
641
+ player_coords = (pos.get('x', 0), pos.get('y', 0))
642
+
643
+ # Get MapStitcher instance - prefer the one from memory_reader if available
644
+ # This ensures we use the instance that has the actual map data
645
+ map_stitcher = map_info.get('_map_stitcher_instance') if map_info else None
646
+ if not map_stitcher:
647
+ map_stitcher = _get_map_stitcher_instance()
648
+
649
+ # Get NPCs if available
650
+ npcs = []
651
+ if include_npcs and 'object_events' in map_info:
652
+ npcs = map_info.get('object_events', [])
653
+
654
+ # Get connections from current area
655
+ connections = []
656
+ if map_info.get('stitched_map_info'):
657
+ current_area = map_info['stitched_map_info'].get('current_area', {})
658
+ connections = current_area.get('connections', [])
659
+
660
+ # Check for pre-generated visual map first (from memory_reader)
661
+ if map_info.get('visual_map'):
662
+ # Use the pre-generated map visualization
663
+ context_parts.append(map_info['visual_map'])
664
+ elif location_name:
665
+ # Generate map display using MapStitcher
666
+ # print( Attempting to generate map for location: '{location_name}'")
667
+ # print( MapStitcher exists: {map_stitcher is not None}")
668
+ if map_stitcher:
669
+ # print( MapStitcher has {len(map_stitcher.map_areas)} areas")
670
+ for map_id in list(map_stitcher.map_areas.keys())[:3]:
671
+ area = map_stitcher.map_areas[map_id]
672
+ # print( Area {map_id}: '{area.location_name}'")
673
+
674
+ map_lines = map_stitcher.generate_location_map_display(
675
+ location_name=location_name,
676
+ player_pos=player_coords,
677
+ npcs=npcs,
678
+ connections=connections
679
+ )
680
+
681
+ if map_lines:
682
+ # print( Generated {len(map_lines)} map lines from MapStitcher")
683
+ context_parts.extend(map_lines)
684
+ # Add exploration statistics
685
+ location_grid = map_stitcher.get_location_grid(location_name)
686
+ if location_grid:
687
+ total_tiles = len(location_grid)
688
+ context_parts.append("")
689
+ context_parts.append(f"Total explored: {total_tiles} tiles")
690
+ else:
691
+ # print( MapStitcher returned empty, falling back to memory tiles")
692
+ # Fallback if MapStitcher doesn't have data for this location - use memory tiles
693
+ pass
694
+ if 'tiles' in map_info and map_info['tiles']:
695
+ context_parts.append(f"\n--- MAP: {location_name.upper()} (from memory) ---")
696
+ _add_local_map_fallback(context_parts, map_info, include_npcs)
697
+ else:
698
+ context_parts.append(f"\n--- MAP: {location_name.upper()} ---")
699
+ context_parts.append("No map data available")
700
+ else:
701
+ # No location name - use local map fallback
702
+ context_parts.append("\n--- LOCAL MAP (Location unknown) ---")
703
+ if 'tiles' in map_info and map_info['tiles']:
704
+ _add_local_map_fallback(context_parts, map_info, include_npcs)
705
+
706
+ # NPC information removed - unreliable detection with incorrect positions
707
+
708
+ # Add stitched map information if available
709
+ stitched_info = _format_stitched_map_info(map_info)
710
+ if stitched_info:
711
+ context_parts.extend(stitched_info)
712
+
713
+ return context_parts
714
+
715
+ def _add_local_map_fallback(context_parts, map_info, include_npcs):
716
+ """Helper function to add local map display as fallback"""
717
+ if 'tiles' in map_info and map_info['tiles']:
718
+ raw_tiles = map_info['tiles']
719
+ # Use default facing direction since memory-based facing is unreliable
720
+ facing = "South" # default
721
+
722
+ # Get player coordinates
723
+ player_coords = map_info.get('player_coords')
724
+
725
+ # Get NPCs if available and include_npcs is True
726
+ npcs = []
727
+ if include_npcs and 'object_events' in map_info:
728
+ npcs = map_info.get('object_events', [])
729
+
730
+ # Use unified LLM formatter for consistency with NPCs if available
731
+ map_display = format_map_for_llm(raw_tiles, facing, npcs, player_coords)
732
+ context_parts.append(map_display)
733
+
734
+ # Add dynamic legend based on symbols in the map
735
+ grid = format_map_grid(raw_tiles, facing, npcs, player_coords)
736
+ legend = generate_dynamic_legend(grid)
737
+ context_parts.append(f"\n{legend}")
738
+
739
+ def _format_world_map_display(stitched_data, full_state_data=None):
740
+ """Format location-specific map display"""
741
+ try:
742
+ # Build separate map for each location
743
+ return _build_stitched_world_map(stitched_data, full_state_data)
744
+
745
+ except Exception as e:
746
+ logger.warning(f"World map generation failed: {e}")
747
+ return []
748
+
749
+ def _get_map_stitcher_instance():
750
+ """Get the MapStitcher instance - always reload from cache for multiprocess compatibility"""
751
+ from utils.map_stitcher import MapStitcher
752
+ # Always create fresh instance to read latest cache
753
+ # This is needed because server and client run in different processes
754
+ return MapStitcher()
755
+
756
+ def save_persistent_world_map(file_path=None):
757
+ """Deprecated - MapStitcher handles all persistence now"""
758
+ # MapStitcher auto-saves, nothing to do here
759
+ pass
760
+
761
+ def load_persistent_world_map(file_path=None):
762
+ """Deprecated - MapStitcher handles all persistence now"""
763
+ # MapStitcher auto-loads, nothing to do here
764
+ pass
765
+
766
+ def clear_persistent_world_map():
767
+ """Clear the MapStitcher's data for testing"""
768
+ global CURRENT_LOCATION, LAST_LOCATION, LAST_TRANSITION
769
+ CURRENT_LOCATION = None
770
+ LAST_LOCATION = None
771
+ LAST_TRANSITION = None
772
+ # Clear MapStitcher data if instance exists
773
+ if MAP_STITCHER_INSTANCE:
774
+ MAP_STITCHER_INSTANCE.map_areas.clear()
775
+ MAP_STITCHER_INSTANCE.warp_connections.clear()
776
+ MAP_STITCHER_INSTANCE.save_to_file()
777
+ # print( Cleared map stitcher data")
778
+
779
+ # Helper function removed - now handled by MapStitcher._is_explorable_edge()
780
+
781
+
782
+ def _build_stitched_world_map(stitched_data, full_state_data=None):
783
+ """Build map display using MapStitcher"""
784
+
785
+ # Get the current area and location info
786
+ current_area = stitched_data.get('current_area', {})
787
+ location_name = current_area.get('name')
788
+
789
+ # Get NPCs if available
790
+ npcs = stitched_data.get('object_events', [])
791
+
792
+ # Use fallback location name if current_area has 'Unknown' or empty name
793
+ fallback_name = stitched_data.get('location_name_fallback')
794
+ if not location_name or location_name == 'Unknown':
795
+ if fallback_name and fallback_name != 'Unknown':
796
+ location_name = fallback_name
797
+ else:
798
+ location_name = 'Unknown'
799
+ elif fallback_name and fallback_name != location_name and fallback_name != 'Unknown':
800
+ location_name = fallback_name
801
+
802
+ # Track location changes and transition coordinates
803
+ global CURRENT_LOCATION, LAST_LOCATION, LAST_TRANSITION
804
+ LAST_TRANSITION = None # Will store transition info
805
+
806
+ # Get MapStitcher instance - prefer the one from memory_reader if available
807
+ # This ensures we use the instance that has the actual map data
808
+ map_stitcher = map_info.get('_map_stitcher_instance') if map_info else None
809
+ if not map_stitcher:
810
+ map_stitcher = _get_map_stitcher_instance()
811
+
812
+ # Get player position for transition tracking
813
+ player_local_pos = stitched_data.get('player_local_pos', (0, 0))
814
+
815
+ # Check if we've changed locations
816
+ if CURRENT_LOCATION != location_name:
817
+ # Store transition information
818
+ if CURRENT_LOCATION is not None and CURRENT_LOCATION != location_name:
819
+ LAST_TRANSITION = {
820
+ 'from_location': CURRENT_LOCATION,
821
+ 'from_coords': player_local_pos, # Use current position as it's the exit point
822
+ 'to_location': location_name,
823
+ 'to_coords': player_local_pos
824
+ }
825
+
826
+ # Trigger MapStitcher save if callback is available
827
+ if MAP_STITCHER_SAVE_CALLBACK:
828
+ try:
829
+ # print( Location transition detected, triggering MapStitcher save...")
830
+ MAP_STITCHER_SAVE_CALLBACK()
831
+ # print( MapStitcher save completed")
832
+ except Exception as e:
833
+ print("Failed to save via MapStitcher callback: {e}")
834
+
835
+ # print( Location transition: {CURRENT_LOCATION} → {location_name} at position {player_local_pos}")
836
+
837
+ LAST_LOCATION = CURRENT_LOCATION
838
+ CURRENT_LOCATION = location_name
839
+
840
+ # Get player position
841
+ player_local_pos = stitched_data.get('player_local_pos')
842
+ if not player_local_pos:
843
+ # Fallback to extracting from current area
844
+ player_local_pos = current_area.get('player_pos', (5, 5)) # Default to center
845
+
846
+ # Get connection info from current_area
847
+ connections = current_area.get('connections', [])
848
+
849
+ # Build the display for this location
850
+ lines = []
851
+
852
+ # Add location transition indicator if we just changed locations
853
+ if LAST_LOCATION and LAST_LOCATION != location_name:
854
+ lines.append(f"\n⚡ LOCATION TRANSITION: {LAST_LOCATION} → {location_name}")
855
+ # Add transition coordinates if available
856
+ if LAST_TRANSITION and LAST_TRANSITION['to_location'] == location_name:
857
+ from_coords = LAST_TRANSITION['from_coords']
858
+ to_coords = LAST_TRANSITION['to_coords']
859
+ from_location = LAST_TRANSITION['from_location']
860
+ lines.append(f"📍 Exited {from_location} at ({from_coords[0]}, {from_coords[1]})")
861
+ lines.append(f"📍 Entered {location_name} at ({to_coords[0]}, {to_coords[1]})")
862
+ # Clear the LAST_LOCATION after showing transition
863
+ LAST_LOCATION = None
864
+ lines.append("")
865
+
866
+ # Use MapStitcher to generate the map display
867
+ map_stitcher = full_state_data.get('map_stitcher') if full_state_data else None
868
+ if not map_stitcher and MAP_STITCHER_INSTANCE:
869
+ map_stitcher = MAP_STITCHER_INSTANCE
870
+
871
+ if map_stitcher:
872
+ # Generate map display using MapStitcher
873
+ map_lines = map_stitcher.generate_location_map_display(
874
+ location_name=location_name,
875
+ player_pos=player_local_pos,
876
+ npcs=npcs,
877
+ connections=connections
878
+ )
879
+ lines.extend(map_lines)
880
+
881
+ # Add exploration statistics
882
+ location_grid = map_stitcher.get_location_grid(location_name)
883
+ if location_grid:
884
+ total_tiles = len(location_grid)
885
+ lines.append("")
886
+ lines.append(f"Total explored in {location_name}: {total_tiles} tiles")
887
+ else:
888
+ # Fallback if no MapStitcher available
889
+ lines.append(f"\n--- MAP: {location_name.upper()} ---")
890
+ lines.append("Map data not available")
891
+
892
+ # Add discovered connection points from our transition tracking
893
+ # print( Checking portal coordinates for location: {location_name}")
894
+ portal_connections_found = False
895
+
896
+ # Check location connections from MapStitcher cache first
897
+ location_connections = _get_location_connections_from_cache()
898
+ if location_connections and location_name in location_connections:
899
+ if not portal_connections_found:
900
+ lines.append("")
901
+ lines.append("Known Portal Coordinates:")
902
+ portal_connections_found = True
903
+ for other_loc, my_coords, their_coords in location_connections[location_name]:
904
+ lines.append(f" At ({my_coords[0]}, {my_coords[1]}) → {other_loc} ({their_coords[0]}, {their_coords[1]})")
905
+
906
+ # Also check MAP_ID_CONNECTIONS if available (from loaded MapStitcher data or HTTP response)
907
+ # print( About to check MAP_ID_CONNECTIONS...")
908
+ # print( full_state_data is: {type(full_state_data)} with keys: {list(full_state_data.keys()) if full_state_data else 'None'}")
909
+ try:
910
+ # Try multiple ways to find MAP_ID_CONNECTIONS
911
+ map_id_connections = None
912
+
913
+ # Method 0: Check if passed via HTTP response (NEW - preferred method)
914
+ # First try location_connections (more accurate), then fall back to portal_connections
915
+ if full_state_data and 'location_connections' in full_state_data:
916
+ location_connections = full_state_data['location_connections']
917
+ # print( Found location_connections in HTTP response: {location_connections}")
918
+
919
+ # Use the location connections display logic
920
+ if location_name in location_connections:
921
+ if not portal_connections_found:
922
+ lines.append("")
923
+ lines.append("Known Portal Coordinates:")
924
+ portal_connections_found = True
925
+ for other_loc, my_coords, their_coords in location_connections[location_name]:
926
+ lines.append(f" At ({my_coords[0]}, {my_coords[1]}) → {other_loc} ({their_coords[0]}, {their_coords[1]})")
927
+ # print( Added location connection: At ({my_coords[0]}, {my_coords[1]}) → {other_loc} ({their_coords[0]}, {their_coords[1]})")
928
+
929
+ elif full_state_data and 'portal_connections' in full_state_data:
930
+ map_id_connections = full_state_data['portal_connections']
931
+ # print( Found MAP_ID_CONNECTIONS in HTTP response: {map_id_connections}")
932
+
933
+ # Get current map ID to find relevant portals
934
+ current_map_id = None
935
+ if stitched_data and 'current_area' in stitched_data:
936
+ area_id = stitched_data['current_area'].get('id', '')
937
+ if area_id:
938
+ try:
939
+ current_map_id = int(area_id, 16) if isinstance(area_id, str) else area_id
940
+ # print( Current map ID: {current_map_id}")
941
+ except ValueError:
942
+ print("Could not parse map ID from: {area_id}")
943
+
944
+ # Try to find current map ID by matching current location name
945
+ if not current_map_id:
946
+ current_location = location_name # Use the location_name parameter
947
+ # print( Trying to find map ID for location: {current_location}")
948
+
949
+ # Look through server's map data to find current map ID
950
+ if 'map' in full_state_data:
951
+ map_info = full_state_data.get('map', {})
952
+ map_location = map_info.get('location_name', '')
953
+ if map_location:
954
+ current_location = map_location
955
+ # print( Using map location: {current_location}")
956
+
957
+ # Match location with portal connections - try all map IDs
958
+ for map_id in map_id_connections.keys():
959
+ # Convert string keys to int if needed
960
+ try:
961
+ test_map_id = int(map_id) if isinstance(map_id, str) else map_id
962
+ # print( Testing map ID {test_map_id} for location '{current_location}'")
963
+
964
+ # For LITTLEROOT TOWN MAYS HOUSE 2F, map_id should be 259
965
+ if current_location == "LITTLEROOT TOWN MAYS HOUSE 2F" and test_map_id == 259:
966
+ current_map_id = test_map_id
967
+ # print( Found matching map ID: {current_map_id}")
968
+ break
969
+ elif current_location == "LITTLEROOT TOWN MAYS HOUSE 1F" and test_map_id == 258:
970
+ current_map_id = test_map_id
971
+ # print( Found matching map ID: {current_map_id}")
972
+ break
973
+ except (ValueError, TypeError):
974
+ continue
975
+
976
+ # Display portal coordinates if we found them
977
+ if current_map_id and current_map_id in map_id_connections:
978
+ if not portal_connections_found:
979
+ lines.append("")
980
+ lines.append("Known Portal Coordinates:")
981
+ portal_connections_found = True
982
+
983
+ for conn in map_id_connections[current_map_id]:
984
+ to_name = conn.get('to_name', 'Unknown Location')
985
+ from_pos = conn.get('from_pos', [0, 0])
986
+ to_pos = conn.get('to_pos', [0, 0])
987
+ lines.append(f" At ({from_pos[0]}, {from_pos[1]}) → {to_name} ({to_pos[0]}, {to_pos[1]})")
988
+ # print( Added portal: At ({from_pos[0]}, {from_pos[1]}) → {to_name} ({to_pos[0]}, {to_pos[1]})")
989
+
990
+ elif stitched_data and 'portal_connections' in stitched_data:
991
+ map_id_connections = stitched_data['portal_connections']
992
+ # print( Found MAP_ID_CONNECTIONS in stitched_data: {map_id_connections}")
993
+
994
+ # Method 1: Check current module (fallback)
995
+ if not map_id_connections:
996
+ current_module = sys.modules[__name__]
997
+ # print( Checking current module for MAP_ID_CONNECTIONS attribute...")
998
+ # print( hasattr(current_module, 'MAP_ID_CONNECTIONS'): {hasattr(current_module, 'MAP_ID_CONNECTIONS')}")
999
+ if hasattr(current_module, 'MAP_ID_CONNECTIONS') and current_module.MAP_ID_CONNECTIONS:
1000
+ map_id_connections = current_module.MAP_ID_CONNECTIONS
1001
+ # print( Found MAP_ID_CONNECTIONS in current module: {map_id_connections}")
1002
+
1003
+ # Method 2: Check global variable (fallback)
1004
+ if not map_id_connections:
1005
+ # print( Checking globals for MAP_ID_CONNECTIONS...")
1006
+ # print( 'MAP_ID_CONNECTIONS' in globals(): {'MAP_ID_CONNECTIONS' in globals()}")
1007
+ try:
1008
+ if 'MAP_ID_CONNECTIONS' in globals():
1009
+ # print( globals()['MAP_ID_CONNECTIONS']: {globals()['MAP_ID_CONNECTIONS']}")
1010
+ if 'MAP_ID_CONNECTIONS' in globals() and globals()['MAP_ID_CONNECTIONS']:
1011
+ map_id_connections = globals()['MAP_ID_CONNECTIONS']
1012
+ # print( Found MAP_ID_CONNECTIONS in globals: {map_id_connections}")
1013
+ except Exception as e:
1014
+ print("Error checking globals: {e}")
1015
+
1016
+ # Method 3: Re-import state_formatter and check (fallback)
1017
+ if not map_id_connections:
1018
+ try:
1019
+ # print( Attempting to re-import state_formatter...")
1020
+ # print( hasattr(sf, 'MAP_ID_CONNECTIONS'): {hasattr(sf, 'MAP_ID_CONNECTIONS')}")
1021
+ if hasattr(sf, 'MAP_ID_CONNECTIONS'):
1022
+ # print( sf.MAP_ID_CONNECTIONS: {sf.MAP_ID_CONNECTIONS}")
1023
+ if hasattr(sf, 'MAP_ID_CONNECTIONS') and sf.MAP_ID_CONNECTIONS:
1024
+ map_id_connections = sf.MAP_ID_CONNECTIONS
1025
+ # print( Found MAP_ID_CONNECTIONS in imported module: {map_id_connections}")
1026
+ except Exception as e:
1027
+ print(f" Error re-importing state_formatter: {e}")
1028
+
1029
+ if map_id_connections:
1030
+ # print( MAP_ID_CONNECTIONS available with {len(map_id_connections)} maps")
1031
+ # print( Available map IDs: {list(map_id_connections.keys())}")
1032
+
1033
+ # Get current map ID from stitched data
1034
+ current_map_id = None
1035
+ # print( stitched_data structure: {stitched_data}")
1036
+ if stitched_data and stitched_data.get('available'):
1037
+ # Try to get map ID from current area
1038
+ current_area = stitched_data.get('current_area', {})
1039
+ map_id_str = current_area.get('id')
1040
+ # print( Current area ID from stitched_data: {map_id_str}")
1041
+ # print( Full current_area: {current_area}")
1042
+ if map_id_str:
1043
+ try:
1044
+ current_map_id = int(map_id_str, 16) # Convert from hex string
1045
+ print(f" Converted to map ID: {current_map_id}")
1046
+ except ValueError:
1047
+ print(f" Failed to convert map ID: {map_id_str}")
1048
+ else:
1049
+ print("No map ID found in current_area: {current_area}")
1050
+ # else:
1051
+ # print( No stitched_data available or not available")
1052
+ # print( stitched_data type: {type(stitched_data)}")
1053
+ # if stitched_data:
1054
+ # print( stitched_data keys: {list(stitched_data.keys()) if isinstance(stitched_data, dict) else 'not a dict'}")
1055
+ # print( stitched_data.get('available'): {stitched_data.get('available') if isinstance(stitched_data, dict) else 'N/A'}")
1056
+
1057
+ if current_map_id and current_map_id in map_id_connections:
1058
+ # print( Found connections for map ID {current_map_id}")
1059
+ if not portal_connections_found:
1060
+ lines.append("")
1061
+ lines.append("Known Portal Coordinates:")
1062
+ portal_connections_found = True
1063
+ for conn in map_id_connections[current_map_id]:
1064
+ to_name = conn['to_name']
1065
+ from_pos = conn['from_pos']
1066
+ to_pos = conn['to_pos']
1067
+ lines.append(f" At ({from_pos[0]}, {from_pos[1]}) → {to_name} ({to_pos[0]}, {to_pos[1]})")
1068
+ else:
1069
+ print("No connections found for map ID {current_map_id} (available: {list(map_id_connections.keys()) if map_id_connections else 'None'})")
1070
+ else:
1071
+ print("MAP_ID_CONNECTIONS not found in any location")
1072
+ except Exception as e:
1073
+ print(f" Error checking MAP_ID_CONNECTIONS: {e}")
1074
+
1075
+ return lines
1076
+
1077
+
1078
+ def _format_stitched_map_info(map_info):
1079
+ """Format stitched map information for the agent"""
1080
+ context_parts = []
1081
+
1082
+ # Check if stitched map info is available
1083
+ stitched_data = map_info.get('stitched_map_info')
1084
+ if not stitched_data or not stitched_data.get('available'):
1085
+ return context_parts
1086
+
1087
+ # Check if world map display with terrain was already shown
1088
+ # Old world map knowledge system removed - replaced by location-based maps with portal coordinates
1089
+ return context_parts
1090
+
1091
+ def _format_game_state(game_data, state_data=None):
1092
+ """Format game state information (for non-battle mode)."""
1093
+ context_parts = []
1094
+
1095
+ if not game_data:
1096
+ return context_parts
1097
+
1098
+ context_parts.append("\n=== GAME STATE ===")
1099
+
1100
+ # Note: Battle info is handled separately in battle mode
1101
+ # This is for showing game state when NOT in battle
1102
+
1103
+ # Dialogue detection and validation (only show when not in battle)
1104
+ is_in_battle = game_data.get('is_in_battle', False) or game_data.get('in_battle', False)
1105
+
1106
+ if not is_in_battle:
1107
+ dialog_text = game_data.get('dialog_text')
1108
+ dialogue_detected = game_data.get('dialogue_detected', {})
1109
+
1110
+ if dialog_text and dialogue_detected.get('has_dialogue', False):
1111
+ # Only show dialogue if it's actually visible and active
1112
+ context_parts.append(f"\n--- DIALOGUE ---")
1113
+ if dialogue_detected.get('confidence') is not None:
1114
+ context_parts.append(f"Detection confidence: {dialogue_detected['confidence']:.1%}")
1115
+ context_parts.append(f"Text: {dialog_text}")
1116
+ # Note: Residual/invisible dialogue text is completely hidden from agent
1117
+
1118
+ # Check if we're in title sequence and override game state
1119
+ player_location = state_data.get('player', {}).get('location', '') if state_data else ''
1120
+ if player_location == 'TITLE_SEQUENCE':
1121
+ context_parts.append(f"Game State: title")
1122
+ elif 'game_state' in game_data:
1123
+ context_parts.append(f"Game State: {game_data['game_state']}")
1124
+
1125
+ # Get player data from state_data if provided
1126
+ player_data = state_data.get('player', {}) if state_data else {}
1127
+
1128
+ # Add helpful prompt for title sequence
1129
+ player_location = player_data.get('location', '')
1130
+ if player_location == 'TITLE_SEQUENCE':
1131
+ context_parts.append("")
1132
+ context_parts.append("💡 TIP: Make sure to choose a fun name for your character!")
1133
+ context_parts.append("Be creative and have fun with the naming!")
1134
+
1135
+ # Add movement preview for overworld navigation (but not during title sequence)
1136
+ if (state_data and not is_in_battle and
1137
+ game_data.get('game_state') == 'overworld' and
1138
+ player_location != 'TITLE_SEQUENCE'):
1139
+ movement_preview = format_movement_preview_for_llm(state_data)
1140
+ if movement_preview:
1141
+ context_parts.append("")
1142
+ context_parts.append(movement_preview)
1143
+
1144
+ return context_parts
1145
+
1146
+ def _format_debug_info(state_data):
1147
+ """Format additional debug information."""
1148
+ context_parts = []
1149
+
1150
+ context_parts.append("\n=== DEBUG INFO ===")
1151
+
1152
+ # Step information
1153
+ if 'step_number' in state_data:
1154
+ context_parts.append(f"Step Number: {state_data['step_number']}")
1155
+
1156
+ if 'status' in state_data:
1157
+ context_parts.append(f"Status: {state_data['status']}")
1158
+
1159
+ # Visual data info
1160
+ if 'visual' in state_data:
1161
+ visual = state_data['visual']
1162
+ if 'resolution' in visual:
1163
+ context_parts.append(f"Resolution: {visual['resolution']}")
1164
+ if 'screenshot_base64' in visual:
1165
+ context_parts.append(f"Screenshot: Available ({len(visual['screenshot_base64'])} chars)")
1166
+
1167
+ return context_parts
1168
+
1169
+ # Convenience functions for specific use cases
1170
+
1171
+ def get_movement_options(state_data):
1172
+ """
1173
+ Extract movement options from traversability data.
1174
+
1175
+ Returns:
1176
+ dict: Direction -> description mapping
1177
+ """
1178
+ map_info = state_data.get('map', {})
1179
+ if 'traversability' not in map_info or not map_info['traversability']:
1180
+ return {}
1181
+
1182
+ traversability = map_info['traversability']
1183
+ center_y = len(traversability) // 2
1184
+ center_x = len(traversability[0]) // 2
1185
+
1186
+ directions = {
1187
+ 'UP': (0, -1), 'DOWN': (0, 1),
1188
+ 'LEFT': (-1, 0), 'RIGHT': (1, 0)
1189
+ }
1190
+
1191
+ movement_options = {}
1192
+ for direction, (dx, dy) in directions.items():
1193
+ new_x, new_y = center_x + dx, center_y + dy
1194
+ if 0 <= new_y < len(traversability) and 0 <= new_x < len(traversability[new_y]):
1195
+ cell = str(traversability[new_y][new_x])
1196
+ if cell == "0":
1197
+ movement_options[direction] = "BLOCKED"
1198
+ elif cell == ".":
1199
+ movement_options[direction] = "Normal path"
1200
+ elif "TALL" in cell:
1201
+ movement_options[direction] = "Tall grass (wild encounters)"
1202
+ elif "WATER" in cell:
1203
+ movement_options[direction] = "Water (need Surf)"
1204
+ else:
1205
+ movement_options[direction] = cell
1206
+ else:
1207
+ movement_options[direction] = "Out of bounds"
1208
+
1209
+ return movement_options
1210
+
1211
+
1212
+ def get_movement_preview(state_data):
1213
+ """
1214
+ Get detailed preview of what happens with each directional movement.
1215
+ Shows new coordinates and tile information for each direction.
1216
+
1217
+ Args:
1218
+ state_data: Complete game state data
1219
+
1220
+ Returns:
1221
+ dict: Direction -> preview info mapping
1222
+ """
1223
+ # Get current player position
1224
+ player_data = state_data.get('player', {})
1225
+ player_position = _get_player_position(player_data)
1226
+
1227
+ if not player_position or 'x' not in player_position or 'y' not in player_position:
1228
+ # print( Movement preview - No player position. player_position={player_position}")
1229
+ return {}
1230
+
1231
+ current_x = int(player_position['x'])
1232
+ current_y = int(player_position['y'])
1233
+
1234
+ # Get map and tile data
1235
+ map_info = state_data.get('map', {})
1236
+ raw_tiles = map_info.get('tiles', [])
1237
+
1238
+ if not raw_tiles:
1239
+ # print( Movement preview - No tiles. map_info keys: {list(map_info.keys()) if map_info else 'None'}")
1240
+ return {}
1241
+
1242
+ directions = {
1243
+ 'UP': (0, -1),
1244
+ 'DOWN': (0, 1),
1245
+ 'LEFT': (-1, 0),
1246
+ 'RIGHT': (1, 0)
1247
+ }
1248
+
1249
+ movement_preview = {}
1250
+
1251
+ # Player is at center of the 15x15 grid
1252
+ center_x = len(raw_tiles[0]) // 2 if raw_tiles and raw_tiles[0] else 7
1253
+ center_y = len(raw_tiles) // 2 if raw_tiles else 7
1254
+
1255
+ # Get the tile the player is currently standing on
1256
+ current_tile_symbol = None
1257
+ if (0 <= center_y < len(raw_tiles) and
1258
+ 0 <= center_x < len(raw_tiles[center_y]) and
1259
+ raw_tiles[center_y] and raw_tiles[center_y][center_x]):
1260
+ current_tile = raw_tiles[center_y][center_x]
1261
+ current_tile_symbol = format_tile_to_symbol(current_tile)
1262
+
1263
+ for direction, (dx, dy) in directions.items():
1264
+ # Calculate new world coordinates
1265
+ new_world_x = current_x + dx
1266
+ new_world_y = current_y + dy
1267
+
1268
+ # Calculate grid position in the tile array
1269
+ grid_x = center_x + dx
1270
+ grid_y = center_y + dy
1271
+
1272
+ preview_info = {
1273
+ 'new_coords': (new_world_x, new_world_y),
1274
+ 'blocked': True,
1275
+ 'tile_symbol': '#',
1276
+ 'tile_description': 'BLOCKED - Out of bounds'
1277
+ }
1278
+
1279
+ # Check if the target position is within the grid bounds
1280
+ if (0 <= grid_y < len(raw_tiles) and
1281
+ 0 <= grid_x < len(raw_tiles[grid_y]) and
1282
+ raw_tiles[grid_y]):
1283
+
1284
+ try:
1285
+ # Get the tile at the target position
1286
+ target_tile = raw_tiles[grid_y][grid_x]
1287
+
1288
+ # Get tile symbol and check if walkable
1289
+ tile_symbol = format_tile_to_symbol(target_tile)
1290
+
1291
+ # Determine if movement is blocked
1292
+ is_blocked = tile_symbol in ['#', 'W'] # Walls and water block movement
1293
+
1294
+ # SPECIAL CASE: If player is standing on stairs/door, don't block the warp direction
1295
+ # Stairs and doors often require moving in a specific direction to activate
1296
+ if current_tile_symbol in ['S', 'D']:
1297
+ # When on stairs/doors, typically you need to move forward to activate them
1298
+ # Don't block any direction when on these tiles to allow proper navigation
1299
+ # This ensures the agent can properly use warps/doors even if the destination
1300
+ # tile might normally be considered blocked
1301
+ if tile_symbol in ['#', 'W']:
1302
+ # Override the blocking for navigation tiles but KEEP original symbol
1303
+ is_blocked = False
1304
+ # DO NOT change tile_symbol - preserve S, D, #, W, etc.
1305
+
1306
+ # Special handling for jump ledges - they're only walkable in their direction
1307
+ if tile_symbol in ['↓', '↑', '←', '→', '↗', '↖', '↘', '↙']:
1308
+ # Map directions to tile symbols
1309
+ ledge_direction_map = {
1310
+ 'UP': '↑',
1311
+ 'DOWN': '↓',
1312
+ 'LEFT': '←',
1313
+ 'RIGHT': '→'
1314
+ }
1315
+
1316
+ # Only allow movement if we're going in the direction the ledge points
1317
+ if direction in ledge_direction_map:
1318
+ allowed_symbol = ledge_direction_map[direction]
1319
+ if tile_symbol != allowed_symbol:
1320
+ is_blocked = True # Block movement in wrong direction
1321
+ else:
1322
+ is_blocked = True # Block diagonal movements for basic directional ledges
1323
+
1324
+ # Get tile description
1325
+ if len(target_tile) >= 2:
1326
+ tile_id, behavior = target_tile[:2]
1327
+
1328
+ # Convert behavior to readable description
1329
+ if hasattr(behavior, 'name'):
1330
+ behavior_name = behavior.name
1331
+ elif isinstance(behavior, int):
1332
+ try:
1333
+ behavior_enum = MetatileBehavior(behavior)
1334
+ behavior_name = behavior_enum.name
1335
+ except (ValueError, ImportError):
1336
+ behavior_name = f"BEHAVIOR_{behavior}"
1337
+ else:
1338
+ behavior_name = str(behavior)
1339
+
1340
+ # Create human-readable description
1341
+ # Check if we're overriding blocking due to being on stairs/door
1342
+ is_override = current_tile_symbol in ['S', 'D'] and not is_blocked and tile_symbol in ['#', 'W']
1343
+
1344
+ if is_override:
1345
+ # We're on stairs/door and this normally blocked tile is walkable
1346
+ if tile_symbol == '#':
1347
+ tile_description = f"Walkable - Warp/Door exit (normally blocked) (ID: {tile_id})"
1348
+ elif tile_symbol == 'W':
1349
+ tile_description = f"Walkable - Warp/Door exit over water (ID: {tile_id})"
1350
+ elif tile_symbol == '.':
1351
+ tile_description = f"Walkable path (ID: {tile_id})"
1352
+ elif tile_symbol == '#':
1353
+ tile_description = f"BLOCKED - Wall/Obstacle (ID: {tile_id}, {behavior_name})"
1354
+ elif tile_symbol == 'W':
1355
+ tile_description = f"BLOCKED - Water (need Surf) (ID: {tile_id})"
1356
+ elif tile_symbol == '~':
1357
+ tile_description = f"Walkable - Tall grass (wild encounters) (ID: {tile_id})"
1358
+ elif tile_symbol == 'D':
1359
+ tile_description = f"Walkable - Door/Entrance (ID: {tile_id})"
1360
+ elif tile_symbol == 'S':
1361
+ tile_description = f"Walkable - Stairs/Warp (ID: {tile_id})"
1362
+ elif tile_symbol in ['↓', '↑', '←', '→', '↗', '↖', '↘', '↙']:
1363
+ # Ledge description based on whether movement is allowed
1364
+ if is_blocked:
1365
+ tile_description = f"BLOCKED - Jump ledge {tile_symbol} (wrong direction) (ID: {tile_id})"
1366
+ else:
1367
+ tile_description = f"Walkable - Jump ledge {tile_symbol} (correct direction) (ID: {tile_id})"
1368
+ else:
1369
+ tile_description = f"Walkable - {behavior_name} (ID: {tile_id})"
1370
+ else:
1371
+ tile_description = "Unknown tile"
1372
+
1373
+ preview_info.update({
1374
+ 'blocked': is_blocked,
1375
+ 'tile_symbol': tile_symbol,
1376
+ 'tile_description': tile_description
1377
+ })
1378
+
1379
+ except (IndexError, TypeError) as e:
1380
+ logger.warning(f"Error analyzing tile at {grid_x}, {grid_y}: {e}")
1381
+ # Keep default blocked values
1382
+ pass
1383
+
1384
+ movement_preview[direction] = preview_info
1385
+
1386
+ return movement_preview
1387
+
1388
+
1389
+ def format_movement_preview_for_llm(state_data):
1390
+ """
1391
+ Format movement preview in a concise format suitable for LLM prompts.
1392
+
1393
+ Args:
1394
+ state_data: Complete game state data
1395
+
1396
+ Returns:
1397
+ str: Formatted movement preview text
1398
+ """
1399
+ preview = get_movement_preview(state_data)
1400
+
1401
+ if not preview:
1402
+ return "Movement preview: Not available"
1403
+
1404
+ lines = ["MOVEMENT PREVIEW:"]
1405
+
1406
+ for direction in ['UP', 'DOWN', 'LEFT', 'RIGHT']:
1407
+ if direction in preview:
1408
+ info = preview[direction]
1409
+ new_x, new_y = info['new_coords']
1410
+ symbol = info['tile_symbol']
1411
+ status = "BLOCKED" if info['blocked'] else "WALKABLE"
1412
+
1413
+ lines.append(f" {direction:5}: ({new_x:3},{new_y:3}) [{symbol}] {status}")
1414
+ # Add brief description for tiles
1415
+ desc = info['tile_description']
1416
+ if info['blocked']:
1417
+ # Special messages for blocked tiles
1418
+ if 'Jump ledge' in desc and 'wrong direction' in desc:
1419
+ lines[-1] += " - Can only jump in arrow direction"
1420
+ elif 'Water' in desc:
1421
+ lines[-1] += " - Need Surf to cross"
1422
+ elif 'Wall' in desc or 'Obstacle' in desc:
1423
+ lines[-1] += " - Impassable"
1424
+ else:
1425
+ # Add brief description for walkable tiles
1426
+ if 'Tall grass' in desc:
1427
+ lines[-1] += " - Tall grass (wild encounters)"
1428
+ elif 'Stairs' in desc or 'Warp' in desc:
1429
+ lines[-1] += " - Stairs/Warp"
1430
+ elif 'Door' in desc or 'Entrance' in desc:
1431
+ lines[-1] += " - Door/Entrance"
1432
+ elif 'Jump ledge' in desc and 'correct direction' in desc:
1433
+ lines[-1] += " - Jump ledge (can jump this way)"
1434
+
1435
+ return "\n".join(lines)
1436
+
1437
+
1438
+ def get_party_health_summary(state_data):
1439
+ """
1440
+ Get a summary of party health status.
1441
+
1442
+ Returns:
1443
+ dict: Summary with healthy_count, total_count, critical_pokemon
1444
+ """
1445
+ player_data = state_data.get('player', {})
1446
+ game_data = state_data.get('game', {})
1447
+ party_data = player_data.get('party') or game_data.get('party')
1448
+
1449
+ if not party_data:
1450
+ return {"healthy_count": 0, "total_count": 0, "critical_pokemon": []}
1451
+
1452
+ pokemon_list = []
1453
+ if isinstance(party_data, dict) and party_data.get('pokemon'):
1454
+ pokemon_list = party_data.get('pokemon', [])
1455
+ elif isinstance(party_data, list):
1456
+ pokemon_list = party_data
1457
+
1458
+ healthy_count = 0
1459
+ critical_pokemon = []
1460
+
1461
+ for i, pokemon in enumerate(pokemon_list[:6]):
1462
+ if pokemon:
1463
+ hp = pokemon.get('current_hp', 0)
1464
+ max_hp = pokemon.get('max_hp', 1)
1465
+ status = pokemon.get('status', 'OK')
1466
+ species = pokemon.get('species_name', pokemon.get('species', 'Unknown Pokemon'))
1467
+
1468
+ # Check if healthy: has HP and no negative status (OK or Normal are both healthy)
1469
+ if hp > 0 and status in ['OK', 'Normal']:
1470
+ healthy_count += 1
1471
+
1472
+ hp_percent = (hp / max_hp * 100) if max_hp > 0 else 0
1473
+ # Mark as critical if low HP or has a status condition
1474
+ if hp_percent < 25 or status not in ['OK', 'Normal']:
1475
+ critical_pokemon.append(f"{species} ({hp_percent:.0f}% HP, {status})")
1476
+
1477
+ return {
1478
+ "healthy_count": healthy_count,
1479
+ "total_count": len(pokemon_list),
1480
+ "critical_pokemon": critical_pokemon
1481
+ }