synth-ai 0.2.8.dev4__py3-none-any.whl → 0.2.23.dev3__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.
Files changed (889) hide show
  1. examples/README.md +1 -0
  2. examples/__init__.py +16 -0
  3. examples/analyze_semantic_words.sh +17 -0
  4. examples/baseline/banking77_baseline.py +243 -0
  5. examples/baseline/banking77_pipeline_baseline.py +294 -0
  6. examples/baseline/crafter_baseline.py +407 -0
  7. examples/baseline/pokemon_red_baseline.py +326 -0
  8. examples/baseline/simple_baseline.py +56 -0
  9. examples/baseline/warming_up_to_rl_baseline.py +239 -0
  10. examples/blog_posts/gepa/README.md +355 -0
  11. examples/blog_posts/gepa/configs/banking77_gepa_local.toml +95 -0
  12. examples/blog_posts/gepa/configs/banking77_gepa_test.toml +80 -0
  13. examples/blog_posts/gepa/configs/banking77_mipro_local.toml +50 -0
  14. examples/blog_posts/gepa/configs/banking77_pipeline_gepa_local.toml +101 -0
  15. examples/blog_posts/gepa/configs/banking77_pipeline_gepa_test.toml +96 -0
  16. examples/blog_posts/gepa/configs/hotpotqa_gepa_local.toml +57 -0
  17. examples/blog_posts/gepa/configs/hotpotqa_gepa_qwen.toml +35 -0
  18. examples/blog_posts/gepa/configs/hotpotqa_mipro_local.toml +51 -0
  19. examples/blog_posts/gepa/configs/hover_gepa_local.toml +57 -0
  20. examples/blog_posts/gepa/configs/hover_gepa_qwen.toml +35 -0
  21. examples/blog_posts/gepa/configs/hover_mipro_local.toml +51 -0
  22. examples/blog_posts/gepa/configs/ifbench_gepa_local.toml +57 -0
  23. examples/blog_posts/gepa/configs/ifbench_gepa_qwen.toml +35 -0
  24. examples/blog_posts/gepa/configs/ifbench_mipro_local.toml +51 -0
  25. examples/blog_posts/gepa/configs/pupa_gepa_local.toml +58 -0
  26. examples/blog_posts/gepa/configs/pupa_mipro_local.toml +52 -0
  27. examples/blog_posts/gepa/deploy_banking77_task_app.sh +54 -0
  28. examples/blog_posts/gepa/gepa_baseline.py +204 -0
  29. examples/blog_posts/gepa/query_prompts_example.py +97 -0
  30. examples/blog_posts/gepa/run_gepa_banking77.sh +112 -0
  31. examples/blog_posts/gepa/run_gepa_banking77_pipeline.sh +163 -0
  32. examples/blog_posts/gepa/task_apps.py +105 -0
  33. examples/blog_posts/gepa/test_gepa_local.sh +67 -0
  34. examples/blog_posts/gepa/verify_banking77_setup.sh +123 -0
  35. examples/blog_posts/mipro/README.md +415 -0
  36. examples/blog_posts/mipro/configs/banking77_mipro_local.toml +91 -0
  37. examples/blog_posts/mipro/configs/banking77_mipro_test.toml +87 -0
  38. examples/blog_posts/mipro/configs/banking77_pipeline_mipro_gemini_flash_lite_local.toml +98 -0
  39. examples/blog_posts/mipro/configs/banking77_pipeline_mipro_gpt41mini_local.toml +96 -0
  40. examples/blog_posts/mipro/configs/banking77_pipeline_mipro_local.toml +94 -0
  41. examples/blog_posts/mipro/configs/banking77_pipeline_mipro_test.toml +170 -0
  42. examples/blog_posts/mipro/deploy_banking77_pipeline_task_app.sh +59 -0
  43. examples/blog_posts/mipro/deploy_banking77_task_app.sh +41 -0
  44. examples/blog_posts/mipro/multi_step.md +79 -0
  45. examples/blog_posts/mipro/run_mipro_banking77.sh +191 -0
  46. examples/blog_posts/mipro/run_mipro_banking77_pipeline.sh +171 -0
  47. examples/blog_posts/mipro/run_mipro_banking77_pipeline_gemini_flash_lite.sh +177 -0
  48. examples/blog_posts/mipro/run_mipro_banking77_pipeline_gpt41mini.sh +173 -0
  49. examples/blog_posts/mipro/verify_banking77_setup.sh +117 -0
  50. examples/blog_posts/pokemon_vl/README.md +98 -0
  51. examples/blog_posts/pokemon_vl/configs/eval_gpt5nano.toml +26 -0
  52. examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +27 -0
  53. examples/blog_posts/pokemon_vl/configs/eval_rl_final.toml +24 -0
  54. examples/blog_posts/pokemon_vl/configs/filter_high_reward.toml +10 -0
  55. examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +43 -0
  56. examples/blog_posts/pokemon_vl/configs/train_sft_qwen4b_vl.toml +40 -0
  57. examples/blog_posts/pokemon_vl/extract_images.py +239 -0
  58. examples/blog_posts/pokemon_vl/pokemon_vl_baseline.py +326 -0
  59. examples/blog_posts/pokemon_vl/run_eval_extract_images.py +209 -0
  60. examples/blog_posts/pokemon_vl/run_qwen_eval_extract_images.py +212 -0
  61. examples/blog_posts/pokemon_vl/text_box_analysis.md +106 -0
  62. examples/blog_posts/warming_up_to_rl/ARCHITECTURE.md +195 -0
  63. examples/blog_posts/warming_up_to_rl/FINAL_TEST_RESULTS.md +127 -0
  64. examples/blog_posts/warming_up_to_rl/INFERENCE_SUCCESS.md +132 -0
  65. examples/blog_posts/warming_up_to_rl/README.md +158 -0
  66. examples/blog_posts/warming_up_to_rl/SMOKE_TESTING.md +164 -0
  67. examples/blog_posts/warming_up_to_rl/SMOKE_TEST_COMPLETE.md +253 -0
  68. examples/blog_posts/warming_up_to_rl/configs/eval_baseline_qwen32b_10x20.toml +25 -0
  69. examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b.toml +25 -0
  70. examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b_10x20.toml +26 -0
  71. examples/blog_posts/warming_up_to_rl/configs/eval_groq_qwen32b.toml +25 -0
  72. examples/blog_posts/warming_up_to_rl/configs/eval_openai_gpt_oss_120b.toml +29 -0
  73. examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +10 -0
  74. examples/blog_posts/warming_up_to_rl/configs/smoke_test.toml +75 -0
  75. examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +91 -0
  76. examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +40 -0
  77. examples/blog_posts/warming_up_to_rl/warming_up_to_rl_baseline.py +187 -0
  78. examples/crafter_debug_render.py +186 -0
  79. examples/dev/qwen3_32b_qlora_4xh100.toml +45 -0
  80. examples/gepa/banking77_pipeline_gepa.toml +96 -0
  81. examples/gepa/multi_stage_gepa_example.toml +84 -0
  82. examples/gepa/run_gepa_banking77_pipeline.sh +157 -0
  83. examples/multi_step/SFT_README.md +147 -0
  84. examples/multi_step/configs/README_verilog_rl.md +77 -0
  85. examples/multi_step/configs/VERILOG_REWARDS.md +103 -0
  86. examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +196 -0
  87. examples/multi_step/configs/crafter_eval_synth_qwen4b.toml +35 -0
  88. examples/multi_step/configs/crafter_eval_text_only_groq_qwen32b.toml +36 -0
  89. examples/multi_step/configs/crafter_rl_outcome.toml +75 -0
  90. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +145 -0
  91. examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +84 -0
  92. examples/multi_step/configs/crafter_rl_stepwise_simple.toml +79 -0
  93. examples/multi_step/configs/crafter_rl_stepwise_simple_NEW_FORMAT.toml +105 -0
  94. examples/multi_step/configs/crafter_sft_qwen30b_lora.toml +62 -0
  95. examples/multi_step/configs/crafter_synth_backend.md +40 -0
  96. examples/multi_step/configs/verilog_eval_groq_qwen32b.toml +31 -0
  97. examples/multi_step/configs/verilog_eval_synth_qwen8b.toml +33 -0
  98. examples/multi_step/configs/verilog_rl_lora.toml +147 -0
  99. examples/multi_step/convert_traces_to_sft.py +84 -0
  100. examples/multi_step/crafter_rl_lora.md +70 -0
  101. examples/multi_step/judges/crafter_backend_judge.py +220 -0
  102. examples/multi_step/judges/verilog_backend_judge.py +234 -0
  103. examples/multi_step/readme.md +48 -0
  104. examples/multi_step/run_sft_qwen30b.sh +45 -0
  105. examples/multi_step/sse_metrics_streaming_notes.md +357 -0
  106. examples/multi_step/task_app_config_notes.md +494 -0
  107. examples/multi_step/verilog_rl_lora.md +218 -0
  108. examples/qwen_coder/README.md +102 -0
  109. examples/qwen_coder/_shared.py +113 -0
  110. examples/qwen_coder/configs/coder_lora_30b.toml +60 -0
  111. examples/qwen_coder/configs/coder_lora_4b.toml +61 -0
  112. examples/qwen_coder/configs/coder_lora_small.toml +57 -0
  113. examples/qwen_coder/generate_dataset.py +98 -0
  114. examples/qwen_coder/infer_ft_smoke.py +65 -0
  115. examples/qwen_coder/infer_prod_proxy.py +73 -0
  116. examples/qwen_coder/infer_via_synth.py +87 -0
  117. examples/qwen_coder/scripts/infer_coder.sh +19 -0
  118. examples/qwen_coder/scripts/train_coder_30b.sh +22 -0
  119. examples/qwen_coder/sft_full_17b.py +103 -0
  120. examples/qwen_coder/sft_lora_30b.py +110 -0
  121. examples/qwen_coder/subset_jsonl.py +39 -0
  122. examples/qwen_coder/todos.md +38 -0
  123. examples/qwen_coder/validate_jsonl.py +60 -0
  124. examples/qwen_vl/BUGS_AND_FIXES.md +232 -0
  125. examples/qwen_vl/IMAGE_VALIDATION_COMPLETE.md +271 -0
  126. examples/qwen_vl/IMAGE_VALIDATION_SUMMARY.md +260 -0
  127. examples/qwen_vl/INFERENCE_SFT_TESTS.md +412 -0
  128. examples/qwen_vl/NEXT_STEPS_2B.md +325 -0
  129. examples/qwen_vl/QUICKSTART.md +327 -0
  130. examples/qwen_vl/QUICKSTART_RL_VISION.md +110 -0
  131. examples/qwen_vl/README.md +152 -0
  132. examples/qwen_vl/RL_VISION_COMPLETE.md +475 -0
  133. examples/qwen_vl/RL_VISION_TESTING.md +333 -0
  134. examples/qwen_vl/SDK_VISION_INTEGRATION.md +328 -0
  135. examples/qwen_vl/SETUP_COMPLETE.md +274 -0
  136. examples/qwen_vl/VISION_TESTS_COMPLETE.md +489 -0
  137. examples/qwen_vl/VLM_PIPELINE_COMPLETE.md +242 -0
  138. examples/qwen_vl/__init__.py +2 -0
  139. examples/qwen_vl/collect_data_via_cli.md +415 -0
  140. examples/qwen_vl/collect_vision_traces.py +368 -0
  141. examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +110 -0
  142. examples/qwen_vl/configs/crafter_vlm_sft_example.toml +59 -0
  143. examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +26 -0
  144. examples/qwen_vl/configs/eval_gpt4o_vision_proper.toml +29 -0
  145. examples/qwen_vl/configs/eval_gpt5nano_vision.toml +26 -0
  146. examples/qwen_vl/configs/eval_qwen3vl_vision.toml +26 -0
  147. examples/qwen_vl/configs/filter_qwen3vl_sft.toml +49 -0
  148. examples/qwen_vl/configs/filter_vision_sft.toml +52 -0
  149. examples/qwen_vl/configs/filter_vision_test.toml +8 -0
  150. examples/qwen_vl/configs/sft_qwen3_vl_2b_test.toml +54 -0
  151. examples/qwen_vl/crafter_gpt5nano_agent.py +308 -0
  152. examples/qwen_vl/crafter_qwen_vl_agent.py +300 -0
  153. examples/qwen_vl/run_vision_comparison.sh +61 -0
  154. examples/qwen_vl/run_vision_sft_pipeline.sh +175 -0
  155. examples/qwen_vl/test_image_validation.py +201 -0
  156. examples/qwen_vl/test_sft_vision_data.py +110 -0
  157. examples/rl/README.md +169 -0
  158. examples/rl/configs/eval_base_qwen.toml +17 -0
  159. examples/rl/configs/eval_rl_qwen.toml +13 -0
  160. examples/rl/configs/rl_from_base_qwen.toml +62 -0
  161. examples/rl/configs/rl_from_base_qwen17.toml +80 -0
  162. examples/rl/configs/rl_from_ft_qwen.toml +37 -0
  163. examples/rl/download_dataset.py +80 -0
  164. examples/rl/run_eval.py +436 -0
  165. examples/rl/run_rl_and_save.py +111 -0
  166. examples/rl/task_app/README.md +21 -0
  167. examples/rl/task_app/math_single_step.py +990 -0
  168. examples/rl/task_app/math_task_app.py +111 -0
  169. examples/run_crafter_demo.sh +10 -0
  170. examples/sdk_prompt_learning_example.py +55 -0
  171. examples/sft/README.md +139 -0
  172. examples/sft/configs/crafter_fft_qwen0p6b.toml +49 -0
  173. examples/sft/configs/crafter_lora_qwen0p6b.toml +49 -0
  174. examples/sft/evaluate.py +117 -0
  175. examples/sft/export_dataset.py +120 -0
  176. examples/sft/generate_traces.py +164 -0
  177. examples/swe/__init__.py +12 -0
  178. examples/swe/task_app/README.md +135 -0
  179. examples/swe/task_app/__init__.py +2 -0
  180. examples/swe/task_app/grpo_swe_mini.py +604 -0
  181. examples/swe/task_app/grpo_swe_mini_task_app.py +124 -0
  182. examples/swe/task_app/hosted/README.md +173 -0
  183. examples/swe/task_app/hosted/__init__.py +5 -0
  184. examples/swe/task_app/hosted/branching.py +143 -0
  185. examples/swe/task_app/hosted/environment_routes.py +1289 -0
  186. examples/swe/task_app/hosted/envs/__init__.py +1 -0
  187. examples/swe/task_app/hosted/envs/crafter/__init__.py +6 -0
  188. examples/swe/task_app/hosted/envs/crafter/app.py +1 -0
  189. examples/swe/task_app/hosted/envs/crafter/environment.py +522 -0
  190. examples/swe/task_app/hosted/envs/crafter/policy.py +478 -0
  191. examples/swe/task_app/hosted/envs/crafter/react_agent.py +108 -0
  192. examples/swe/task_app/hosted/envs/crafter/shared.py +305 -0
  193. examples/swe/task_app/hosted/envs/crafter/tools.py +47 -0
  194. examples/swe/task_app/hosted/envs/mini_swe/__init__.py +8 -0
  195. examples/swe/task_app/hosted/envs/mini_swe/environment.py +1191 -0
  196. examples/swe/task_app/hosted/envs/mini_swe/policy.py +355 -0
  197. examples/swe/task_app/hosted/envs/mini_swe/shared.py +83 -0
  198. examples/swe/task_app/hosted/envs/mini_swe/tools.py +96 -0
  199. examples/swe/task_app/hosted/hosted_app.py +204 -0
  200. examples/swe/task_app/hosted/inference/__init__.py +5 -0
  201. examples/swe/task_app/hosted/inference/openai_client.py +584 -0
  202. examples/swe/task_app/hosted/main.py +100 -0
  203. examples/swe/task_app/hosted/policy_routes.py +1094 -0
  204. examples/swe/task_app/hosted/registry.py +195 -0
  205. examples/swe/task_app/hosted/rollout.py +1905 -0
  206. examples/swe/task_app/hosted/storage/__init__.py +5 -0
  207. examples/swe/task_app/hosted/storage/volume.py +211 -0
  208. examples/swe/task_app/hosted/test_agents.py +161 -0
  209. examples/swe/task_app/hosted/test_service.py +136 -0
  210. examples/swe/task_app/hosted/utils.py +62 -0
  211. examples/swe/task_app/morph_backend.py +178 -0
  212. examples/task_apps/IMAGE_ONLY_EVAL_QUICKSTART.md +258 -0
  213. examples/task_apps/TESTING.md +275 -0
  214. examples/task_apps/banking77/__init__.py +6 -0
  215. examples/task_apps/banking77/banking77_task_app.py +912 -0
  216. examples/task_apps/banking77/deploy_wrapper.py +46 -0
  217. examples/task_apps/banking77_pipeline/__init__.py +6 -0
  218. examples/task_apps/banking77_pipeline/banking77_pipeline_task_app.py +489 -0
  219. examples/task_apps/banking77_pipeline/deploy_wrapper.py +50 -0
  220. examples/task_apps/crafter/CREATE_SFT_DATASET.md +286 -0
  221. examples/task_apps/crafter/EVAL_IMAGE_ONLY_RESULTS.md +152 -0
  222. examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +187 -0
  223. examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +281 -0
  224. examples/task_apps/crafter/QUERY_EXAMPLES.md +203 -0
  225. examples/task_apps/crafter/README_IMAGE_ONLY_EVAL.md +316 -0
  226. examples/task_apps/crafter/eval_image_only_gpt4o.toml +28 -0
  227. examples/task_apps/crafter/eval_text_only_groq_llama.toml +36 -0
  228. examples/task_apps/crafter/filter_sft_dataset.toml +16 -0
  229. examples/task_apps/crafter/task_app/README.md +42 -0
  230. examples/task_apps/crafter/task_app/__init__.py +5 -0
  231. examples/task_apps/crafter/task_app/grpo_crafter.py +1055 -0
  232. examples/task_apps/crafter/task_app/grpo_crafter_task_app.py +146 -0
  233. examples/task_apps/crafter/task_app/synth_envs_hosted/README.md +173 -0
  234. examples/task_apps/crafter/task_app/synth_envs_hosted/__init__.py +5 -0
  235. examples/task_apps/crafter/task_app/synth_envs_hosted/branching.py +143 -0
  236. examples/task_apps/crafter/task_app/synth_envs_hosted/environment_routes.py +1226 -0
  237. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/__init__.py +1 -0
  238. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
  239. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
  240. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/environment.py +532 -0
  241. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +583 -0
  242. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +122 -0
  243. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/shared.py +305 -0
  244. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
  245. examples/task_apps/crafter/task_app/synth_envs_hosted/hosted_app.py +253 -0
  246. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/__init__.py +5 -0
  247. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +999 -0
  248. examples/task_apps/crafter/task_app/synth_envs_hosted/main.py +100 -0
  249. examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +1252 -0
  250. examples/task_apps/crafter/task_app/synth_envs_hosted/registry.py +195 -0
  251. examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +2233 -0
  252. examples/task_apps/crafter/task_app/synth_envs_hosted/storage/__init__.py +5 -0
  253. examples/task_apps/crafter/task_app/synth_envs_hosted/storage/volume.py +211 -0
  254. examples/task_apps/crafter/task_app/synth_envs_hosted/test_agents.py +161 -0
  255. examples/task_apps/crafter/task_app/synth_envs_hosted/test_service.py +136 -0
  256. examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +411 -0
  257. examples/task_apps/dev/pokemon_emerald/__init__.py +2 -0
  258. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/README.md +811 -0
  259. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/__init__.py +120 -0
  260. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/action.py +160 -0
  261. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/memory.py +155 -0
  262. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/perception.py +69 -0
  263. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/planning.py +96 -0
  264. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/simple.py +1502 -0
  265. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/system_prompt.py +4 -0
  266. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/grab_map.py +68 -0
  267. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/manual.py +216 -0
  268. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/__init__.py +35 -0
  269. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emerald_utils.py +631 -0
  270. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emulator.py +1544 -0
  271. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/enums.py +1428 -0
  272. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/memory_reader.py +4848 -0
  273. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/types.py +41 -0
  274. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/utils.py +298 -0
  275. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pyproject.toml +95 -0
  276. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/run.py +204 -0
  277. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/app.py +2152 -0
  278. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/client.py +429 -0
  279. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/frame_server.py +155 -0
  280. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/README.md +78 -0
  281. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/run_tests.py +122 -0
  282. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_direct.py +76 -0
  283. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_prompts.py +413 -0
  284. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_battle_state_formatting.py +204 -0
  285. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection.py +133 -0
  286. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection_comprehensive.py +229 -0
  287. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_direct_agent_emulator.py +300 -0
  288. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_fps_adjustment_pytest.py +205 -0
  289. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_direct.py +200 -0
  290. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_transition.py +284 -0
  291. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_map_ground_truth_comparison.py +468 -0
  292. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_memory_map.py +575 -0
  293. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_server_map_validation.py +311 -0
  294. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_torchic_state.py +259 -0
  295. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/anticheat.py +372 -0
  296. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/checkpoint.py +296 -0
  297. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/error_handler.py +275 -0
  298. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/get_local_ip.py +22 -0
  299. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/helpers.py +44 -0
  300. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/llm_logger.py +514 -0
  301. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_formatter.py +415 -0
  302. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher.py +1763 -0
  303. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher_singleton.py +33 -0
  304. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_trimmer.py +106 -0
  305. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_visualizer.py +334 -0
  306. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/ocr_dialogue.py +1020 -0
  307. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/recording.py +188 -0
  308. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/state_formatter.py +1481 -0
  309. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/vlm.py +862 -0
  310. examples/task_apps/dev/pokemon_emerald/modal_app.py +114 -0
  311. examples/task_apps/dev/pokemon_emerald/task_app/README.md +81 -0
  312. examples/task_apps/dev/pokemon_emerald/task_app/__init__.py +6 -0
  313. examples/task_apps/dev/pokemon_emerald/task_app/pokemon_emerald.py +685 -0
  314. examples/task_apps/enron/__init__.py +2 -0
  315. examples/task_apps/enron/eval_groq_qwen32.toml +16 -0
  316. examples/task_apps/enron/filter_sft.toml +5 -0
  317. examples/task_apps/enron/task_app/README.md +14 -0
  318. examples/task_apps/enron/task_app/__init__.py +1 -0
  319. examples/task_apps/enron/task_app/grpo_enron.py +906 -0
  320. examples/task_apps/enron/task_app/grpo_enron_task_app.py +146 -0
  321. examples/task_apps/enron/tests/__init__.py +4 -0
  322. examples/task_apps/enron/tests/conftest.py +115 -0
  323. examples/task_apps/enron/tests/integration/__init__.py +4 -0
  324. examples/task_apps/enron/tests/integration/test_enron_eval.py +179 -0
  325. examples/task_apps/enron/tests/integration/test_enron_rollout.py +135 -0
  326. examples/task_apps/enron/tests/unit/__init__.py +4 -0
  327. examples/task_apps/enron/tests/unit/test_enron_environment.py +126 -0
  328. examples/task_apps/gepa_benchmarks/__init__.py +7 -0
  329. examples/task_apps/gepa_benchmarks/common.py +260 -0
  330. examples/task_apps/gepa_benchmarks/hotpotqa_task_app.py +507 -0
  331. examples/task_apps/gepa_benchmarks/hover_task_app.py +436 -0
  332. examples/task_apps/gepa_benchmarks/ifbench_task_app.py +563 -0
  333. examples/task_apps/gepa_benchmarks/pupa_task_app.py +460 -0
  334. examples/task_apps/math/README.md +21 -0
  335. examples/task_apps/math/math_single_step.py +1000 -0
  336. examples/task_apps/math/math_task_app.py +115 -0
  337. examples/task_apps/pokemon_battle/__init__.py +2 -0
  338. examples/task_apps/pokemon_battle/modal_app.py +104 -0
  339. examples/task_apps/pokemon_battle/task_app/README.md +68 -0
  340. examples/task_apps/pokemon_battle/task_app/__init__.py +6 -0
  341. examples/task_apps/pokemon_battle/task_app/pokemon_showdown.py +932 -0
  342. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_COMPLETE.md +283 -0
  343. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_STATUS.md +155 -0
  344. examples/task_apps/pokemon_red/README.md +356 -0
  345. examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +428 -0
  346. examples/task_apps/pokemon_red/__init__.py +3 -0
  347. examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +30 -0
  348. examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +224 -0
  349. examples/task_apps/pokemon_red/pallet_town_rl_config.toml +75 -0
  350. examples/task_apps/pokemon_red/task_app.py +1048 -0
  351. examples/task_apps/pokemon_red/test_pallet_town_rewards.py +193 -0
  352. examples/task_apps/sokoban/README.md +306 -0
  353. examples/task_apps/sokoban/__init__.py +3 -0
  354. examples/task_apps/sokoban/eval_groq_qwen32.toml +16 -0
  355. examples/task_apps/sokoban/eval_openai_gpt5.toml +16 -0
  356. examples/task_apps/sokoban/filter_sft.toml +5 -0
  357. examples/task_apps/sokoban/task_app.py +1058 -0
  358. examples/task_apps/sokoban/tests/__init__.py +4 -0
  359. examples/task_apps/sokoban/tests/conftest.py +113 -0
  360. examples/task_apps/sokoban/tests/integration/__init__.py +4 -0
  361. examples/task_apps/sokoban/tests/integration/test_sokoban_eval.py +57 -0
  362. examples/task_apps/sokoban/tests/integration/test_sokoban_rollout.py +198 -0
  363. examples/task_apps/sokoban/tests/unit/__init__.py +4 -0
  364. examples/task_apps/sokoban/tests/unit/test_sokoban_environment.py +114 -0
  365. examples/task_apps/verilog/__init__.py +1 -0
  366. examples/task_apps/verilog/eval_groq_qwen32b.toml +22 -0
  367. examples/task_apps/verilog/filter_sft.toml +5 -0
  368. examples/task_apps/verilog/task_app/README.md +12 -0
  369. examples/task_apps/verilog/task_app/__init__.py +1 -0
  370. examples/task_apps/verilog/task_app/grpo_verilog.py +1166 -0
  371. examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +145 -0
  372. examples/task_apps/verilog/tests/__init__.py +4 -0
  373. examples/task_apps/verilog/tests/conftest.py +115 -0
  374. examples/task_apps/verilog/tests/integration/__init__.py +4 -0
  375. examples/task_apps/verilog/tests/integration/test_verilog_eval.py +181 -0
  376. examples/task_apps/verilog/tests/integration/test_verilog_rollout.py +55 -0
  377. examples/task_apps/verilog/tests/unit/__init__.py +4 -0
  378. examples/task_apps/verilog/tests/unit/test_verilog_scoring.py +118 -0
  379. examples/tunnel_gepa_banking77/README.md +106 -0
  380. examples/tunnel_gepa_banking77/banking77_gepa_tunnel.toml +95 -0
  381. examples/tunnel_gepa_banking77/keep_tunnel_running.py +60 -0
  382. examples/tunnel_gepa_banking77/run_gepa_with_tunnel.sh +226 -0
  383. examples/vlm/PROPOSAL.md +53 -0
  384. examples/vlm/README.md +68 -0
  385. examples/vlm/configs/crafter_vlm_gpt4o.toml +49 -0
  386. examples/vlm/crafter_image_only_agent.py +207 -0
  387. examples/vlm/crafter_openai_vlm_agent.py +275 -0
  388. examples/vlm/filter_image_rows.py +63 -0
  389. examples/vlm/run_crafter_vlm_benchmark.py +316 -0
  390. examples/warming_up_to_rl/_utils.py +92 -0
  391. examples/warming_up_to_rl/analyze_trace_db.py +422 -0
  392. examples/warming_up_to_rl/configs/crafter_fft.toml +53 -0
  393. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +54 -0
  394. examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +22 -0
  395. examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +15 -0
  396. examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +24 -0
  397. examples/warming_up_to_rl/configs/eval_stepwise_complex.toml +35 -0
  398. examples/warming_up_to_rl/configs/eval_stepwise_consistent.toml +26 -0
  399. examples/warming_up_to_rl/configs/eval_stepwise_per_achievement.toml +36 -0
  400. examples/warming_up_to_rl/configs/eval_stepwise_simple.toml +32 -0
  401. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +85 -0
  402. examples/warming_up_to_rl/configs/rl_from_ft.toml +58 -0
  403. examples/warming_up_to_rl/export_trace_sft.py +837 -0
  404. examples/warming_up_to_rl/groq_test.py +97 -0
  405. examples/warming_up_to_rl/manage_secrets.py +131 -0
  406. examples/warming_up_to_rl/old/event_rewards.md +234 -0
  407. examples/warming_up_to_rl/old/notes.md +73 -0
  408. examples/warming_up_to_rl/readme.md +110 -0
  409. examples/warming_up_to_rl/run_eval.py +736 -0
  410. examples/warming_up_to_rl/run_fft_and_save.py +380 -0
  411. examples/warming_up_to_rl/run_local_rollout.py +239 -0
  412. examples/warming_up_to_rl/run_local_rollout_modal.py +248 -0
  413. examples/warming_up_to_rl/run_local_rollout_parallel.py +405 -0
  414. examples/warming_up_to_rl/run_local_rollout_traced.py +477 -0
  415. examples/warming_up_to_rl/run_rl_and_save.py +124 -0
  416. examples/warming_up_to_rl/run_rollout_remote.py +156 -0
  417. examples/warming_up_to_rl/task_app/README.md +42 -0
  418. examples/warming_up_to_rl/task_app/grpo_crafter.py +876 -0
  419. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +135 -0
  420. examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +173 -0
  421. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +5 -0
  422. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +143 -0
  423. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +1226 -0
  424. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -0
  425. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
  426. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
  427. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +522 -0
  428. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +454 -0
  429. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +108 -0
  430. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +305 -0
  431. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
  432. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +253 -0
  433. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +5 -0
  434. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +729 -0
  435. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +100 -0
  436. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +1114 -0
  437. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +195 -0
  438. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +1891 -0
  439. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +5 -0
  440. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +211 -0
  441. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +161 -0
  442. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +137 -0
  443. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +129 -0
  444. examples/workflows/math_rl/configs/eval_base_qwen.toml +15 -0
  445. examples/workflows/math_rl/configs/eval_rl_qwen.toml +11 -0
  446. examples/workflows/math_rl/configs/rl_from_base_qwen.toml +62 -0
  447. examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +80 -0
  448. examples/workflows/math_rl/configs/rl_from_ft_qwen.toml +35 -0
  449. examples/workflows/math_rl/download_dataset.py +80 -0
  450. examples/workflows/math_rl/run_eval.py +436 -0
  451. examples/workflows/math_rl/run_rl_and_save.py +111 -0
  452. synth_ai/__init__.py +47 -23
  453. synth_ai/_utils/__init__.py +47 -0
  454. synth_ai/_utils/base_url.py +10 -0
  455. synth_ai/_utils/http.py +10 -0
  456. synth_ai/_utils/prompts.py +10 -0
  457. synth_ai/_utils/task_app_state.py +12 -0
  458. synth_ai/_utils/user_config.py +10 -0
  459. synth_ai/api/models/supported.py +514 -0
  460. synth_ai/api/train/__init__.py +63 -0
  461. synth_ai/api/train/builders.py +473 -0
  462. synth_ai/api/train/cli.py +1185 -0
  463. synth_ai/api/train/config_finder.py +246 -0
  464. synth_ai/api/train/configs/__init__.py +65 -0
  465. synth_ai/api/train/configs/prompt_learning.py +496 -0
  466. synth_ai/api/train/configs/rl.py +188 -0
  467. synth_ai/api/train/configs/sft.py +99 -0
  468. synth_ai/api/train/configs/shared.py +81 -0
  469. synth_ai/api/train/env_resolver.py +352 -0
  470. synth_ai/api/train/pollers.py +91 -0
  471. synth_ai/api/train/prompt_learning.py +425 -0
  472. synth_ai/api/train/sft.py +390 -0
  473. synth_ai/api/train/supported_algos.py +147 -0
  474. synth_ai/api/train/task_app.py +195 -0
  475. synth_ai/api/train/utils.py +244 -0
  476. synth_ai/api/train/validators.py +1117 -0
  477. synth_ai/api/tunnel.py +49 -0
  478. synth_ai/auth/credentials.py +94 -0
  479. synth_ai/baseline/__init__.py +25 -0
  480. synth_ai/baseline/config.py +209 -0
  481. synth_ai/baseline/discovery.py +214 -0
  482. synth_ai/baseline/execution.py +146 -0
  483. synth_ai/cfgs.py +227 -0
  484. synth_ai/cli/__init__.py +90 -45
  485. synth_ai/cli/_modal_wrapper.py +31 -0
  486. synth_ai/cli/_storage.py +20 -0
  487. synth_ai/cli/_typer_patch.py +47 -0
  488. synth_ai/cli/_validate_task_app.py +29 -0
  489. synth_ai/cli/balance.py +16 -4
  490. synth_ai/cli/calc.py +36 -21
  491. synth_ai/cli/claude.py +70 -0
  492. synth_ai/cli/codex.py +267 -0
  493. synth_ai/cli/commands/__init__.py +18 -0
  494. synth_ai/cli/commands/baseline/__init__.py +12 -0
  495. synth_ai/cli/commands/baseline/core.py +637 -0
  496. synth_ai/cli/commands/baseline/list.py +93 -0
  497. synth_ai/cli/commands/demo/__init__.py +6 -0
  498. synth_ai/cli/commands/demo/core.py +163 -0
  499. synth_ai/cli/commands/eval/__init__.py +19 -0
  500. synth_ai/cli/commands/eval/core.py +1112 -0
  501. synth_ai/cli/commands/eval/errors.py +81 -0
  502. synth_ai/cli/commands/eval/validation.py +133 -0
  503. synth_ai/cli/commands/filter/__init__.py +12 -0
  504. synth_ai/cli/commands/filter/core.py +424 -0
  505. synth_ai/cli/commands/filter/errors.py +55 -0
  506. synth_ai/cli/commands/filter/validation.py +77 -0
  507. synth_ai/cli/commands/help/__init__.py +185 -0
  508. synth_ai/cli/commands/help/core.py +72 -0
  509. synth_ai/cli/commands/smoke/__init__.py +7 -0
  510. synth_ai/cli/commands/smoke/core.py +1437 -0
  511. synth_ai/cli/commands/status/__init__.py +66 -0
  512. synth_ai/cli/commands/status/client.py +192 -0
  513. synth_ai/cli/commands/status/config.py +92 -0
  514. synth_ai/cli/commands/status/errors.py +20 -0
  515. synth_ai/cli/commands/status/formatters.py +164 -0
  516. synth_ai/cli/commands/status/subcommands/__init__.py +9 -0
  517. synth_ai/cli/commands/status/subcommands/files.py +79 -0
  518. synth_ai/cli/commands/status/subcommands/jobs.py +334 -0
  519. synth_ai/cli/commands/status/subcommands/models.py +79 -0
  520. synth_ai/cli/commands/status/subcommands/pricing.py +22 -0
  521. synth_ai/cli/commands/status/subcommands/runs.py +81 -0
  522. synth_ai/cli/commands/status/subcommands/session.py +183 -0
  523. synth_ai/cli/commands/status/subcommands/summary.py +47 -0
  524. synth_ai/cli/commands/status/subcommands/usage.py +203 -0
  525. synth_ai/cli/commands/status/utils.py +114 -0
  526. synth_ai/cli/commands/train/__init__.py +53 -0
  527. synth_ai/cli/commands/train/core.py +21 -0
  528. synth_ai/cli/commands/train/errors.py +117 -0
  529. synth_ai/cli/commands/train/judge_schemas.py +200 -0
  530. synth_ai/cli/commands/train/judge_validation.py +305 -0
  531. synth_ai/cli/commands/train/validation.py +386 -0
  532. synth_ai/cli/demo.py +32 -140
  533. synth_ai/cli/deploy.py +233 -0
  534. synth_ai/cli/eval/__init__.py +36 -0
  535. synth_ai/cli/eval/core.py +5 -0
  536. synth_ai/cli/eval/errors.py +31 -0
  537. synth_ai/cli/eval/validation.py +5 -0
  538. synth_ai/cli/filter/__init__.py +28 -0
  539. synth_ai/cli/filter/core.py +5 -0
  540. synth_ai/cli/filter/errors.py +23 -0
  541. synth_ai/cli/filter/validation.py +5 -0
  542. synth_ai/cli/legacy_root_backup.py +28 -22
  543. synth_ai/cli/lib/__init__.py +10 -0
  544. synth_ai/cli/lib/task_app_discovery.py +7 -0
  545. synth_ai/cli/lib/task_app_env.py +518 -0
  546. synth_ai/cli/mcp.py +34 -0
  547. synth_ai/cli/modal_serve/__init__.py +12 -0
  548. synth_ai/cli/modal_serve/core.py +14 -0
  549. synth_ai/cli/modal_serve/errors.py +8 -0
  550. synth_ai/cli/modal_serve/validation.py +11 -0
  551. synth_ai/cli/opencode.py +256 -0
  552. synth_ai/cli/recent.py +13 -7
  553. synth_ai/cli/rl_demo.py +166 -114
  554. synth_ai/cli/root.py +143 -112
  555. synth_ai/cli/serve/__init__.py +12 -0
  556. synth_ai/cli/serve/core.py +14 -0
  557. synth_ai/cli/serve/errors.py +8 -0
  558. synth_ai/cli/serve/validation.py +11 -0
  559. synth_ai/cli/setup.py +49 -0
  560. synth_ai/cli/status.py +7 -125
  561. synth_ai/cli/task_app_deploy.py +7 -0
  562. synth_ai/cli/task_app_list.py +25 -0
  563. synth_ai/cli/task_app_modal_serve.py +11 -0
  564. synth_ai/cli/task_app_serve.py +11 -0
  565. synth_ai/cli/task_apps.py +3134 -0
  566. synth_ai/cli/traces.py +9 -5
  567. synth_ai/cli/train/__init__.py +12 -0
  568. synth_ai/cli/train/core.py +21 -0
  569. synth_ai/cli/train/errors.py +8 -0
  570. synth_ai/cli/train/validation.py +24 -0
  571. synth_ai/cli/train.py +5 -0
  572. synth_ai/cli/turso.py +73 -0
  573. synth_ai/cli/watch.py +13 -18
  574. synth_ai/demos/__init__.py +10 -0
  575. synth_ai/demos/core/__init__.py +28 -1
  576. synth_ai/demos/core/cli.py +745 -416
  577. synth_ai/demos/crafter/__init__.py +1 -0
  578. synth_ai/demos/crafter/crafter_fft_4b.toml +55 -0
  579. synth_ai/demos/crafter/grpo_crafter_task_app.py +185 -0
  580. synth_ai/demos/crafter/rl_from_base_qwen4b.toml +74 -0
  581. synth_ai/demos/demo_registry.py +176 -0
  582. synth_ai/demos/demo_task_apps/__init__.py +7 -1
  583. synth_ai/demos/demo_task_apps/core.py +75 -37
  584. synth_ai/demos/demo_task_apps/crafter/__init__.py +1 -0
  585. synth_ai/demos/demo_task_apps/crafter/configs/crafter_fft_4b.toml +53 -0
  586. synth_ai/demos/demo_task_apps/crafter/configs/rl_from_base_qwen4b.toml +73 -0
  587. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +184 -0
  588. synth_ai/demos/demo_task_apps/math/_common.py +1 -2
  589. synth_ai/demos/demo_task_apps/math/app.py +2 -1
  590. synth_ai/demos/demo_task_apps/math/config.toml +55 -110
  591. synth_ai/demos/demo_task_apps/math/deploy_modal.py +3 -6
  592. synth_ai/demos/demo_task_apps/math/modal_task_app.py +491 -166
  593. synth_ai/demos/demo_task_apps/math/task_app_entry.py +37 -0
  594. synth_ai/demos/math/__init__.py +1 -0
  595. synth_ai/demos/math/_common.py +16 -0
  596. synth_ai/demos/math/app.py +38 -0
  597. synth_ai/demos/math/config.toml +76 -0
  598. synth_ai/demos/math/deploy_modal.py +54 -0
  599. synth_ai/demos/math/modal_task_app.py +703 -0
  600. synth_ai/demos/math/task_app_entry.py +51 -0
  601. synth_ai/environments/environment/core.py +7 -1
  602. synth_ai/environments/examples/bandit/engine.py +12 -5
  603. synth_ai/environments/examples/bandit/environment.py +0 -1
  604. synth_ai/environments/examples/bandit/taskset.py +4 -4
  605. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +7 -4
  606. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +9 -5
  607. synth_ai/environments/examples/crafter_classic/environment.py +93 -2
  608. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +4 -3
  609. synth_ai/environments/examples/enron/engine.py +7 -2
  610. synth_ai/environments/examples/enron/environment.py +68 -0
  611. synth_ai/environments/examples/red/engine.py +60 -12
  612. synth_ai/environments/examples/red/engine_helpers/memory_map.py +7 -0
  613. synth_ai/environments/examples/red/engine_helpers/reward_components.py +151 -179
  614. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_progression.py +477 -0
  615. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +32 -0
  616. synth_ai/environments/examples/red/environment.py +86 -0
  617. synth_ai/environments/examples/red/trace_hooks_v3.py +168 -0
  618. synth_ai/environments/examples/sokoban/taskset.py +116 -0
  619. synth_ai/environments/examples/verilog/engine.py +104 -12
  620. synth_ai/environments/examples/wordle/environment.py +0 -1
  621. synth_ai/environments/reproducibility/tree.py +5 -6
  622. synth_ai/environments/service/app.py +11 -12
  623. synth_ai/environments/service/core_routes.py +10 -9
  624. synth_ai/environments/stateful/engine.py +1 -1
  625. synth_ai/environments/tasks/core.py +1 -0
  626. synth_ai/environments/tasks/filters.py +5 -6
  627. synth_ai/environments/tasks/utils.py +4 -5
  628. synth_ai/evals/__init__.py +15 -0
  629. synth_ai/evals/base.py +14 -5
  630. synth_ai/evals/client.py +82 -0
  631. synth_ai/evals/types.py +42 -0
  632. synth_ai/http.py +8 -22
  633. synth_ai/http_client.py +45 -12
  634. synth_ai/inference/__init__.py +0 -2
  635. synth_ai/inference/client.py +21 -7
  636. synth_ai/jobs/client.py +129 -80
  637. synth_ai/judge_schemas.py +127 -0
  638. synth_ai/learning/__init__.py +51 -6
  639. synth_ai/learning/algorithms.py +14 -0
  640. synth_ai/learning/client.py +122 -30
  641. synth_ai/learning/config.py +2 -40
  642. synth_ai/learning/constants.py +0 -2
  643. synth_ai/learning/ft_client.py +4 -56
  644. synth_ai/learning/health.py +14 -8
  645. synth_ai/learning/jobs.py +43 -47
  646. synth_ai/learning/prompt_learning_client.py +276 -0
  647. synth_ai/learning/prompt_learning_types.py +185 -0
  648. synth_ai/{rl → learning/rl}/__init__.py +14 -5
  649. synth_ai/learning/rl/client.py +269 -0
  650. synth_ai/learning/rl/config.py +31 -0
  651. synth_ai/{rl → learning/rl}/contracts.py +5 -10
  652. synth_ai/{rl → learning/rl}/env_keys.py +45 -16
  653. synth_ai/learning/rl/secrets.py +13 -0
  654. synth_ai/learning/rl_client.py +2 -253
  655. synth_ai/learning/sft/__init__.py +29 -0
  656. synth_ai/learning/sft/client.py +68 -0
  657. synth_ai/learning/sft/config.py +270 -0
  658. synth_ai/learning/sft/data.py +698 -0
  659. synth_ai/learning/sse.py +25 -26
  660. synth_ai/learning/validators.py +29 -25
  661. synth_ai/mcp/__init__.py +5 -0
  662. synth_ai/mcp/__main__.py +8 -0
  663. synth_ai/mcp/main.py +254 -0
  664. synth_ai/mcp/setup.py +100 -0
  665. synth_ai/modal.py +257 -0
  666. synth_ai/pricing/__init__.py +3 -0
  667. synth_ai/pricing/model_pricing.py +64 -0
  668. synth_ai/session/__init__.py +75 -0
  669. synth_ai/session/client.py +383 -0
  670. synth_ai/session/constants.py +63 -0
  671. synth_ai/session/exceptions.py +105 -0
  672. synth_ai/session/manager.py +139 -0
  673. synth_ai/session/models.py +89 -0
  674. synth_ai/session/query.py +110 -0
  675. synth_ai/spec/__init__.py +46 -0
  676. synth_ai/spec/dataclasses.py +149 -0
  677. synth_ai/spec/loader.py +144 -0
  678. synth_ai/spec/serializer.py +199 -0
  679. synth_ai/spec/validation.py +250 -0
  680. synth_ai/streaming/__init__.py +29 -0
  681. synth_ai/streaming/config.py +94 -0
  682. synth_ai/streaming/handlers.py +589 -0
  683. synth_ai/streaming/streamer.py +320 -0
  684. synth_ai/streaming/types.py +95 -0
  685. synth_ai/task/__init__.py +116 -3
  686. synth_ai/task/apps/__init__.py +132 -0
  687. synth_ai/task/auth.py +165 -0
  688. synth_ai/task/client.py +167 -0
  689. synth_ai/task/config.py +261 -0
  690. synth_ai/task/contracts.py +173 -57
  691. synth_ai/task/datasets.py +108 -0
  692. synth_ai/task/errors.py +50 -0
  693. synth_ai/task/health.py +17 -11
  694. synth_ai/task/inference_api.py +101 -0
  695. synth_ai/task/json.py +111 -0
  696. synth_ai/task/proxy.py +251 -0
  697. synth_ai/task/rubrics/__init__.py +55 -0
  698. synth_ai/task/rubrics/loaders.py +156 -0
  699. synth_ai/task/rubrics/models.py +57 -0
  700. synth_ai/task/rubrics/scoring.py +116 -0
  701. synth_ai/task/rubrics/strict.py +149 -0
  702. synth_ai/task/rubrics.py +219 -0
  703. synth_ai/task/server.py +432 -0
  704. synth_ai/task/trace_correlation_helpers.py +328 -0
  705. synth_ai/task/tracing_utils.py +95 -0
  706. synth_ai/task/validators.py +449 -6
  707. synth_ai/task/vendors.py +59 -0
  708. synth_ai/tracing_v3/__init__.py +4 -0
  709. synth_ai/tracing_v3/abstractions.py +21 -4
  710. synth_ai/tracing_v3/config.py +167 -22
  711. synth_ai/tracing_v3/constants.py +21 -0
  712. synth_ai/tracing_v3/db_config.py +42 -29
  713. synth_ai/tracing_v3/decorators.py +80 -45
  714. synth_ai/tracing_v3/examples/basic_usage.py +15 -9
  715. synth_ai/tracing_v3/hooks.py +6 -4
  716. synth_ai/tracing_v3/llm_call_record_helpers.py +161 -61
  717. synth_ai/tracing_v3/migration_helper.py +1 -2
  718. synth_ai/tracing_v3/replica_sync.py +12 -7
  719. synth_ai/tracing_v3/serialization.py +130 -0
  720. synth_ai/tracing_v3/session_tracer.py +86 -21
  721. synth_ai/tracing_v3/storage/base.py +98 -12
  722. synth_ai/tracing_v3/storage/config.py +63 -16
  723. synth_ai/tracing_v3/storage/factory.py +11 -9
  724. synth_ai/tracing_v3/storage/utils.py +15 -11
  725. synth_ai/tracing_v3/trace_utils.py +317 -0
  726. synth_ai/tracing_v3/turso/__init__.py +8 -21
  727. synth_ai/tracing_v3/turso/daemon.py +123 -15
  728. synth_ai/tracing_v3/turso/models.py +5 -2
  729. synth_ai/tracing_v3/turso/native_manager.py +1293 -0
  730. synth_ai/tracing_v3/utils.py +5 -4
  731. synth_ai/tunnel.py +143 -0
  732. synth_ai/tunnel_deploy.py +278 -0
  733. synth_ai/types.py +8 -0
  734. synth_ai/urls.py +11 -0
  735. synth_ai/utils/__init__.py +166 -0
  736. synth_ai/utils/agents.py +74 -0
  737. synth_ai/utils/apps.py +152 -0
  738. synth_ai/utils/base_url.py +94 -0
  739. synth_ai/utils/bin.py +39 -0
  740. synth_ai/utils/claude.py +36 -0
  741. synth_ai/utils/cli.py +284 -0
  742. synth_ai/utils/config.py +81 -0
  743. synth_ai/utils/env.py +346 -0
  744. synth_ai/utils/errors.py +85 -0
  745. synth_ai/utils/http.py +172 -0
  746. synth_ai/utils/json.py +72 -0
  747. synth_ai/utils/log_filter.py +99 -0
  748. synth_ai/utils/logging.py +198 -0
  749. synth_ai/utils/modal.py +299 -0
  750. synth_ai/utils/paths.py +95 -0
  751. synth_ai/utils/process.py +233 -0
  752. synth_ai/utils/prompts.py +39 -0
  753. synth_ai/utils/sqld.py +122 -0
  754. synth_ai/utils/ssl.py +25 -0
  755. synth_ai/utils/task_app_discovery.py +882 -0
  756. synth_ai/utils/task_app_env.py +186 -0
  757. synth_ai/utils/task_app_state.py +318 -0
  758. synth_ai/utils/tunnel/__init__.py +12 -0
  759. synth_ai/utils/tunnel/config.py +55 -0
  760. synth_ai/utils/user_config.py +137 -0
  761. synth_ai/uvicorn.py +77 -0
  762. synth_ai-0.2.23.dev3.dist-info/METADATA +357 -0
  763. synth_ai-0.2.23.dev3.dist-info/RECORD +983 -0
  764. {synth_ai-0.2.8.dev4.dist-info → synth_ai-0.2.23.dev3.dist-info}/entry_points.txt +0 -1
  765. {synth_ai-0.2.8.dev4.dist-info → synth_ai-0.2.23.dev3.dist-info}/top_level.txt +1 -0
  766. synth_ai/cli/man.py +0 -106
  767. synth_ai/core/experiment.py +0 -15
  768. synth_ai/core/system.py +0 -15
  769. synth_ai/environments/examples/sokoban/units/astar_common.py +0 -95
  770. synth_ai/experimental/synth_oss.py +0 -446
  771. synth_ai/handshake.py +0 -63
  772. synth_ai/install_sqld.sh +0 -40
  773. synth_ai/learning/offline/dpo.py +0 -0
  774. synth_ai/learning/offline/providers.py +0 -7
  775. synth_ai/learning/offline/sft.py +0 -0
  776. synth_ai/learning/offline/shared.py +0 -0
  777. synth_ai/learning/online/grpo.py +0 -0
  778. synth_ai/learning/online/irft.py +0 -0
  779. synth_ai/learning/prompts/banking77_injection_eval.py +0 -168
  780. synth_ai/learning/prompts/gepa.py +0 -0
  781. synth_ai/learning/prompts/hello_world_in_context_injection_ex.py +0 -213
  782. synth_ai/learning/prompts/mipro.py +0 -289
  783. synth_ai/learning/prompts/random_search.py +0 -246
  784. synth_ai/learning/prompts/run_mipro_banking77.py +0 -172
  785. synth_ai/learning/prompts/run_random_search_banking77.py +0 -324
  786. synth_ai/lm/__init__.py +0 -51
  787. synth_ai/lm/caching/constants.py +0 -6
  788. synth_ai/lm/caching/dbs.py +0 -0
  789. synth_ai/lm/caching/ephemeral.py +0 -102
  790. synth_ai/lm/caching/handler.py +0 -137
  791. synth_ai/lm/caching/initialize.py +0 -11
  792. synth_ai/lm/caching/persistent.py +0 -114
  793. synth_ai/lm/config.py +0 -110
  794. synth_ai/lm/constants.py +0 -32
  795. synth_ai/lm/core/__init__.py +0 -8
  796. synth_ai/lm/core/all.py +0 -73
  797. synth_ai/lm/core/exceptions.py +0 -7
  798. synth_ai/lm/core/main.py +0 -319
  799. synth_ai/lm/core/main_v3.py +0 -594
  800. synth_ai/lm/core/synth_models.py +0 -48
  801. synth_ai/lm/core/vendor_clients.py +0 -188
  802. synth_ai/lm/cost/monitor.py +0 -1
  803. synth_ai/lm/cost/statefulness.py +0 -1
  804. synth_ai/lm/injection.py +0 -80
  805. synth_ai/lm/overrides.py +0 -206
  806. synth_ai/lm/provider_support/__init__.py +0 -8
  807. synth_ai/lm/provider_support/anthropic.py +0 -972
  808. synth_ai/lm/provider_support/openai.py +0 -1139
  809. synth_ai/lm/provider_support/suppress_logging.py +0 -31
  810. synth_ai/lm/structured_outputs/handler.py +0 -440
  811. synth_ai/lm/structured_outputs/inject.py +0 -297
  812. synth_ai/lm/structured_outputs/rehabilitate.py +0 -185
  813. synth_ai/lm/tools/__init__.py +0 -3
  814. synth_ai/lm/tools/base.py +0 -172
  815. synth_ai/lm/unified_interface.py +0 -202
  816. synth_ai/lm/vendors/base.py +0 -81
  817. synth_ai/lm/vendors/core/anthropic_api.py +0 -387
  818. synth_ai/lm/vendors/core/gemini_api.py +0 -292
  819. synth_ai/lm/vendors/core/mistral_api.py +0 -322
  820. synth_ai/lm/vendors/core/openai_api.py +0 -225
  821. synth_ai/lm/vendors/core/synth_dev_api.py +0 -0
  822. synth_ai/lm/vendors/local/ollama.py +0 -0
  823. synth_ai/lm/vendors/openai_standard.py +0 -780
  824. synth_ai/lm/vendors/openai_standard_responses.py +0 -256
  825. synth_ai/lm/vendors/retries.py +0 -22
  826. synth_ai/lm/vendors/supported/custom_endpoint.py +0 -417
  827. synth_ai/lm/vendors/supported/deepseek.py +0 -69
  828. synth_ai/lm/vendors/supported/grok.py +0 -75
  829. synth_ai/lm/vendors/supported/groq.py +0 -16
  830. synth_ai/lm/vendors/supported/ollama.py +0 -15
  831. synth_ai/lm/vendors/supported/openrouter.py +0 -74
  832. synth_ai/lm/vendors/supported/together.py +0 -11
  833. synth_ai/lm/vendors/synth_client.py +0 -808
  834. synth_ai/lm/warmup.py +0 -186
  835. synth_ai/rl/secrets.py +0 -19
  836. synth_ai/scripts/verify_rewards.py +0 -100
  837. synth_ai/tracing/__init__.py +0 -30
  838. synth_ai/tracing_v1/__init__.py +0 -33
  839. synth_ai/tracing_v3/turso/manager.py +0 -760
  840. synth_ai/v0/tracing/abstractions.py +0 -224
  841. synth_ai/v0/tracing/base_client.py +0 -91
  842. synth_ai/v0/tracing/client_manager.py +0 -131
  843. synth_ai/v0/tracing/config.py +0 -142
  844. synth_ai/v0/tracing/context.py +0 -146
  845. synth_ai/v0/tracing/decorators.py +0 -682
  846. synth_ai/v0/tracing/events/__init__.py +0 -0
  847. synth_ai/v0/tracing/events/manage.py +0 -147
  848. synth_ai/v0/tracing/events/scope.py +0 -86
  849. synth_ai/v0/tracing/events/store.py +0 -228
  850. synth_ai/v0/tracing/immediate_client.py +0 -151
  851. synth_ai/v0/tracing/local.py +0 -18
  852. synth_ai/v0/tracing/log_client_base.py +0 -73
  853. synth_ai/v0/tracing/retry_queue.py +0 -186
  854. synth_ai/v0/tracing/trackers.py +0 -515
  855. synth_ai/v0/tracing/upload.py +0 -512
  856. synth_ai/v0/tracing/utils.py +0 -9
  857. synth_ai/v0/tracing_v1/__init__.py +0 -16
  858. synth_ai/v0/tracing_v1/abstractions.py +0 -224
  859. synth_ai/v0/tracing_v1/base_client.py +0 -91
  860. synth_ai/v0/tracing_v1/client_manager.py +0 -131
  861. synth_ai/v0/tracing_v1/config.py +0 -142
  862. synth_ai/v0/tracing_v1/context.py +0 -146
  863. synth_ai/v0/tracing_v1/decorators.py +0 -703
  864. synth_ai/v0/tracing_v1/events/__init__.py +0 -0
  865. synth_ai/v0/tracing_v1/events/manage.py +0 -147
  866. synth_ai/v0/tracing_v1/events/scope.py +0 -86
  867. synth_ai/v0/tracing_v1/events/store.py +0 -228
  868. synth_ai/v0/tracing_v1/immediate_client.py +0 -151
  869. synth_ai/v0/tracing_v1/local.py +0 -18
  870. synth_ai/v0/tracing_v1/log_client_base.py +0 -73
  871. synth_ai/v0/tracing_v1/retry_queue.py +0 -186
  872. synth_ai/v0/tracing_v1/trackers.py +0 -515
  873. synth_ai/v0/tracing_v1/upload.py +0 -527
  874. synth_ai/v0/tracing_v1/utils.py +0 -9
  875. synth_ai/zyk/__init__.py +0 -30
  876. synth_ai-0.2.8.dev4.dist-info/METADATA +0 -129
  877. synth_ai-0.2.8.dev4.dist-info/RECORD +0 -420
  878. {synth_ai/lm/caching → examples/task_apps}/__init__.py +0 -0
  879. {synth_ai/lm/cost → examples/task_apps/crafter}/__init__.py +0 -0
  880. {synth_ai/lm/structured_outputs → examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server}/__init__.py +0 -0
  881. {synth_ai/lm/vendors → examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests}/__init__.py +0 -0
  882. {synth_ai/lm/vendors/core → examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils}/__init__.py +0 -0
  883. {synth_ai/lm/vendors/local → examples/task_apps/math}/__init__.py +0 -0
  884. {synth_ai/lm/vendors/supported → examples/workflows}/__init__.py +0 -0
  885. {synth_ai/v0/tracing → examples/workflows/math_rl}/__init__.py +0 -0
  886. /synth_ai/{compound/cais.py → cli/__main__.py} +0 -0
  887. /synth_ai/{learning/filtering.py → py.typed} +0 -0
  888. {synth_ai-0.2.8.dev4.dist-info → synth_ai-0.2.23.dev3.dist-info}/WHEEL +0 -0
  889. {synth_ai-0.2.8.dev4.dist-info → synth_ai-0.2.23.dev3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1763 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Map Stitching System for Pokemon Emerald
4
+
5
+ Connects previously seen map areas with warps and transitions to create
6
+ a unified world map showing connections between routes, towns, and buildings.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ from typing import Dict, List, Tuple, Optional, Set, Any
13
+ from dataclasses import dataclass, asdict
14
+ from pathlib import Path
15
+ from pokemon_env.enums import MapLocation, MetatileBehavior
16
+ from utils import state_formatter
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ @dataclass
21
+ class WarpConnection:
22
+ """Represents a connection between two map areas"""
23
+ from_map_id: int # (map_bank << 8) | map_number
24
+ to_map_id: int
25
+ from_position: Tuple[int, int] # Player position when warp triggered
26
+ to_position: Tuple[int, int] # Player position after warp
27
+ warp_type: str # "door", "stairs", "exit", "route_transition"
28
+ direction: str # "north", "south", "east", "west", "up", "down"
29
+
30
+ def get_reverse_connection(self) -> 'WarpConnection':
31
+ """Get the reverse direction of this warp"""
32
+ reverse_dirs = {
33
+ "north": "south", "south": "north",
34
+ "east": "west", "west": "east",
35
+ "up": "down", "down": "up"
36
+ }
37
+ return WarpConnection(
38
+ from_map_id=self.to_map_id,
39
+ to_map_id=self.from_map_id,
40
+ from_position=self.to_position,
41
+ to_position=self.from_position,
42
+ warp_type=self.warp_type,
43
+ direction=reverse_dirs.get(self.direction, "unknown")
44
+ )
45
+
46
+ @dataclass
47
+ class MapArea:
48
+ """Represents a single map area with its data"""
49
+ map_id: int # (map_bank << 8) | map_number
50
+ location_name: str
51
+ map_data: List[List[Tuple]] # Raw tile data from memory
52
+ player_last_position: Tuple[int, int] # Last known player position
53
+ warp_tiles: List[Tuple[int, int, str]] # (x, y, warp_type) positions
54
+ boundaries: Dict[str, int] # north, south, east, west limits
55
+ visited_count: int
56
+ first_seen: float # timestamp
57
+ last_seen: float # timestamp
58
+ overworld_coords: Optional[Tuple[int, int]] = None # (X, Y) in overworld coordinate system
59
+
60
+ def get_map_bounds(self) -> Tuple[int, int, int, int]:
61
+ """Return (min_x, min_y, max_x, max_y) for this map"""
62
+ height = len(self.map_data)
63
+ width = len(self.map_data[0]) if height > 0 else 0
64
+ return (0, 0, width - 1, height - 1)
65
+
66
+ def has_warp_at(self, x: int, y: int) -> Optional[str]:
67
+ """Check if there's a warp at the given position"""
68
+ for wx, wy, warp_type in self.warp_tiles:
69
+ if wx == x and wy == y:
70
+ return warp_type
71
+ return None
72
+
73
+ class MapStitcher:
74
+ """Main class for managing map stitching and connections"""
75
+
76
+ def __init__(self, save_file: str = None):
77
+ # Setup cache directory
78
+ self.cache_dir = ".pokeagent_cache"
79
+ os.makedirs(self.cache_dir, exist_ok=True)
80
+
81
+ # Use cache folder for default save file
82
+ if save_file is None:
83
+ save_file = os.path.join(self.cache_dir, "map_stitcher_data.json")
84
+ self.save_file = Path(save_file)
85
+ self.map_areas: Dict[int, MapArea] = {}
86
+ self.warp_connections: List[WarpConnection] = []
87
+ self.pending_warps: List[Dict] = [] # Track potential warps
88
+ self.last_map_id: Optional[int] = None
89
+ self.last_position: Optional[Tuple[int, int]] = None
90
+
91
+ # Load existing data
92
+ self.load_from_file()
93
+
94
+ def _merge_map_tiles(self, area: MapArea, new_tiles: List[List[Tuple]], player_pos: Tuple[int, int]):
95
+ """Merge new tiles into existing map data, building up complete map over time.
96
+
97
+ This is the core stitching logic - it takes the new 15x15 view around
98
+ the player and merges it into the accumulated map data for this area.
99
+ """
100
+ if not new_tiles:
101
+ return
102
+
103
+ # Get dimensions of new tile data (usually 15x15)
104
+ new_height = len(new_tiles)
105
+ new_width = len(new_tiles[0]) if new_tiles else 0
106
+
107
+ if new_height == 0 or new_width == 0:
108
+ return
109
+
110
+ # Calculate the offset of the new tiles relative to player position
111
+ # The player is at the center of the new tiles
112
+ center_y = new_height // 2
113
+ center_x = new_width // 2
114
+
115
+ # If this is the first data for this area, initialize with a large empty grid
116
+ if area.map_data is None or not area.map_data:
117
+ # Create a 100x100 grid initially (can expand as needed)
118
+ # Use None to indicate unexplored tiles
119
+ area.map_data = [[None for _ in range(100)] for _ in range(100)]
120
+ # Track the actual bounds of explored area
121
+ area.explored_bounds = {
122
+ 'min_x': 50, 'max_x': 50,
123
+ 'min_y': 50, 'max_y': 50
124
+ }
125
+ # Place player at center of our coordinate system initially
126
+ area.origin_offset = {'x': 50 - player_pos[0], 'y': 50 - player_pos[1]}
127
+
128
+ # Ensure origin_offset exists
129
+ if not hasattr(area, 'origin_offset'):
130
+ area.origin_offset = {'x': 50 - player_pos[0], 'y': 50 - player_pos[1]}
131
+
132
+ # Get the offset to map player coordinates to our stored grid
133
+ offset_x = area.origin_offset.get('x', 0)
134
+ offset_y = area.origin_offset.get('y', 0)
135
+
136
+ # CRITICAL: Check for unreasonable coordinate jumps that indicate a map transition error
137
+ # If the player position would require massive grid expansion, it's likely a different map
138
+ grid_center_x = player_pos[0] + offset_x
139
+ grid_center_y = player_pos[1] + offset_y
140
+
141
+ MAX_REASONABLE_SIZE = 200 # Maximum reasonable size for a single map area
142
+
143
+ # Check if this would cause unreasonable expansion
144
+ if (grid_center_x < -50 or grid_center_x > MAX_REASONABLE_SIZE + 50 or
145
+ grid_center_y < -50 or grid_center_y > MAX_REASONABLE_SIZE + 50):
146
+ logger.warning(f"Detected unreasonable coordinate jump for map {area.map_id:04X}: "
147
+ f"player at {player_pos}, grid position would be ({grid_center_x}, {grid_center_y})")
148
+ logger.warning(f"This likely indicates map areas are being incorrectly merged. "
149
+ f"Resetting origin offset for this area.")
150
+
151
+ # Reset the map data for this area to prevent corruption
152
+ area.map_data = [[None for _ in range(100)] for _ in range(100)]
153
+ area.explored_bounds = {
154
+ 'min_x': 50, 'max_x': 50,
155
+ 'min_y': 50, 'max_y': 50
156
+ }
157
+ area.origin_offset = {'x': 50 - player_pos[0], 'y': 50 - player_pos[1]}
158
+ offset_x = area.origin_offset['x']
159
+ offset_y = area.origin_offset['y']
160
+
161
+ # Merge the new tiles into the existing map
162
+ for dy in range(new_height):
163
+ for dx in range(new_width):
164
+ # Calculate the world position of this tile
165
+ world_x = player_pos[0] - center_x + dx
166
+ world_y = player_pos[1] - center_y + dy
167
+
168
+ # Calculate the position in our stored grid
169
+ grid_x = world_x + offset_x
170
+ grid_y = world_y + offset_y
171
+
172
+ # Sanity check to prevent excessive memory usage
173
+ if grid_x < 0 or grid_y < 0 or grid_x >= MAX_REASONABLE_SIZE or grid_y >= MAX_REASONABLE_SIZE:
174
+ logger.debug(f"Skipping tile at grid position ({grid_x}, {grid_y}) - out of reasonable bounds")
175
+ continue
176
+
177
+ # Expand grid if necessary (but within reasonable limits)
178
+ if grid_y >= len(area.map_data) and grid_y < MAX_REASONABLE_SIZE:
179
+ # Expand vertically
180
+ expansion_needed = min(grid_y - len(area.map_data) + 1,
181
+ MAX_REASONABLE_SIZE - len(area.map_data))
182
+ for _ in range(expansion_needed):
183
+ area.map_data.append([None] * len(area.map_data[0]))
184
+
185
+ if grid_x >= len(area.map_data[0]) and grid_x < MAX_REASONABLE_SIZE:
186
+ # Expand horizontally
187
+ new_width_needed = min(grid_x + 1, MAX_REASONABLE_SIZE)
188
+ for row in area.map_data:
189
+ expansion = new_width_needed - len(row)
190
+ if expansion > 0:
191
+ row.extend([None] * expansion)
192
+
193
+ # Store the tile (always update with latest data)
194
+ if 0 <= grid_x < len(area.map_data[0]) and 0 <= grid_y < len(area.map_data):
195
+ tile = new_tiles[dy][dx]
196
+ # Store all tiles including 1023 (which represents walls/boundaries)
197
+ # The display logic will handle showing them correctly
198
+ if tile:
199
+ area.map_data[grid_y][grid_x] = tile
200
+
201
+ # Update explored bounds for all tiles including boundaries
202
+ # tile_id 1023 represents trees/walls at map edges - we want to include these
203
+ tile_id = tile[0] if tile and len(tile) > 0 else None
204
+ if tile_id is not None: # Include all tiles, even 1023
205
+ if not hasattr(area, 'explored_bounds'):
206
+ area.explored_bounds = {
207
+ 'min_x': grid_x, 'max_x': grid_x,
208
+ 'min_y': grid_y, 'max_y': grid_y
209
+ }
210
+ else:
211
+ area.explored_bounds['min_x'] = min(area.explored_bounds['min_x'], grid_x)
212
+ area.explored_bounds['max_x'] = max(area.explored_bounds['max_x'], grid_x)
213
+ area.explored_bounds['min_y'] = min(area.explored_bounds['min_y'], grid_y)
214
+ area.explored_bounds['max_y'] = max(area.explored_bounds['max_y'], grid_y)
215
+
216
+ def get_map_id(self, map_bank: int, map_number: int) -> int:
217
+ """Convert map bank/number to unique ID"""
218
+ return (map_bank << 8) | map_number
219
+
220
+ def decode_map_id(self, map_id: int) -> Tuple[int, int]:
221
+ """Convert map ID back to bank/number"""
222
+ return (map_id >> 8, map_id & 0xFF)
223
+
224
+ def update_save_file(self, new_save_file: str):
225
+ """Update the save file path and reload data"""
226
+ self.save_file = Path(new_save_file)
227
+ # Clear current data and reload from new file
228
+ self.map_areas = {}
229
+ self.warp_connections = []
230
+ self.pending_warps = []
231
+ self.load_from_file()
232
+
233
+ def update_map_area(self, map_bank: int, map_number: int, location_name: str,
234
+ map_data: List[List[Tuple]], player_pos: Tuple[int, int],
235
+ timestamp: float, overworld_coords: Optional[Tuple[int, int]] = None):
236
+ """Update or create a map area with new data"""
237
+ map_id = self.get_map_id(map_bank, map_number)
238
+
239
+ # Skip map 0 (startup/initialization state) as it's not a real location
240
+ if map_id == 0:
241
+ logger.debug(f"Skipping map 0 (startup state)")
242
+ return
243
+
244
+ # Validate map ID is reasonable
245
+ if map_id < 0 or map_id > 0xFFFF:
246
+ logger.error(f"Invalid map ID {map_id} from bank={map_bank}, number={map_number}")
247
+ return
248
+
249
+ # Validate player position - check for invalid values
250
+ if player_pos:
251
+ px, py = player_pos
252
+ # Check for invalid coordinates (65535 = 0xFFFF is a common error value)
253
+ if px < 0 or px > 1000 or py < 0 or py > 1000 or px == 0xFFFF or py == 0xFFFF:
254
+ logger.warning(f"Invalid player position {player_pos} for map {map_id:04X}, ignoring update")
255
+ return
256
+
257
+ if map_id in self.map_areas:
258
+ # Update existing area - we're revisiting this location
259
+ area = self.map_areas[map_id]
260
+ logger.info(f"Revisiting existing map area {area.location_name} (ID: {map_id:04X})")
261
+ area.visited_count = getattr(area, 'visited_count', 0) + 1
262
+ # Update location name if we have a better one (not empty or "Unknown")
263
+ if location_name and location_name.strip() and location_name != "Unknown":
264
+ if area.location_name == "Unknown" or not area.location_name:
265
+ logger.info(f"Updating location name for map {map_id:04X}: '{area.location_name}' -> '{location_name}'")
266
+ area.location_name = location_name
267
+ # Try to resolve other unknown names since we got new location info
268
+ self.resolve_unknown_location_names()
269
+ elif area.location_name != location_name:
270
+ # Check if this is a significant name difference that might indicate a problem
271
+ name1_words = set(area.location_name.lower().split())
272
+ name2_words = set(location_name.lower().split())
273
+
274
+ # If the names share no common words, this might be a misidentified map
275
+ if not name1_words.intersection(name2_words):
276
+ logger.warning(f"Significant location name mismatch for map {map_id:04X}: "
277
+ f"existing='{area.location_name}' vs new='{location_name}'. "
278
+ f"This might indicate incorrect map identification.")
279
+ else:
280
+ logger.info(f"Found different location name for map {map_id:04X}: '{area.location_name}' vs '{location_name}', keeping current")
281
+ else:
282
+ area.location_name = location_name
283
+
284
+ # MERGE map data instead of replacing - this is the key to stitching!
285
+ if map_data and player_pos:
286
+ # When revisiting, check if player position makes sense with existing map
287
+ if hasattr(area, 'origin_offset') and area.origin_offset:
288
+ expected_grid_x = player_pos[0] + area.origin_offset['x']
289
+ expected_grid_y = player_pos[1] + area.origin_offset['y']
290
+
291
+ # Check if player position is reasonable for this map
292
+ if (0 <= expected_grid_x <= 200 and 0 <= expected_grid_y <= 200):
293
+ # Position is reasonable - merge tiles
294
+ self._merge_map_tiles(area, map_data, player_pos)
295
+ logger.debug(f"Merged {len(map_data) * len(map_data[0]) if map_data else 0} new tiles into area")
296
+ else:
297
+ logger.warning(f"Player position {player_pos} seems incorrect for map {map_id:04X} "
298
+ f"(would be at grid {expected_grid_x},{expected_grid_y})")
299
+ else:
300
+ # First visit to this area after loading - merge normally
301
+ self._merge_map_tiles(area, map_data, player_pos)
302
+ logger.debug(f"Merged {len(map_data) * len(map_data[0]) if map_data else 0} new tiles into area")
303
+
304
+ area.player_last_position = player_pos
305
+ area.last_seen = timestamp
306
+ # Remove deprecated fields - keep it simple
307
+ logger.debug(f"Updated map area {area.location_name} (ID: {map_id:04X})")
308
+ else:
309
+ # Create new area
310
+ # Try to resolve location name from map ID if empty
311
+ if not location_name or not location_name.strip():
312
+ # Import and use the location mapping
313
+ try:
314
+ map_enum = MapLocation(map_id)
315
+ final_location_name = map_enum.name.replace('_', ' ').title()
316
+ logger.info(f"Resolved location name for map {map_id:04X}: {final_location_name}")
317
+ except ValueError:
318
+ # Fallback for unknown map IDs
319
+ final_location_name = f"Map_{map_id:04X}"
320
+ logger.debug(f"Unknown map ID {map_id:04X}, using fallback name")
321
+ else:
322
+ final_location_name = location_name
323
+
324
+ area = MapArea(
325
+ map_id=map_id,
326
+ location_name=final_location_name,
327
+ map_data=None, # Start with empty data - will be populated by merge
328
+ player_last_position=player_pos,
329
+ warp_tiles=[], # Deprecated - not needed
330
+ boundaries={"north": 0, "south": 10, "west": 0, "east": 10}, # Simple default
331
+ visited_count=1,
332
+ first_seen=timestamp,
333
+ last_seen=timestamp,
334
+ overworld_coords=None # Not needed
335
+ )
336
+ self.map_areas[map_id] = area
337
+
338
+ # Now merge the initial tiles
339
+ if map_data and player_pos:
340
+ self._merge_map_tiles(area, map_data, player_pos)
341
+ logger.debug(f"Initialized new area with {len(map_data) * len(map_data[0]) if map_data else 0} tiles")
342
+ logger.info(f"Added new map area: {final_location_name} (ID: {map_id:04X}) as separate location")
343
+
344
+ # Check for area transitions and potential warp connections
345
+ # print(f"🔍 Transition check: last_map_id={self.last_map_id}, current_map_id={map_id}, last_pos={self.last_position}, current_pos={player_pos}")
346
+ if self.last_map_id is not None and self.last_map_id != map_id:
347
+ logger.info(f"🔄 Map transition detected! {self.last_map_id} -> {map_id}")
348
+
349
+ # Use the last position stored in the previous map area for the from_pos
350
+ # This is the actual exit point from the previous map
351
+ from_area = self.map_areas.get(self.last_map_id)
352
+ from_pos = from_area.player_last_position if from_area else self.last_position
353
+
354
+ logger.info(f"🔄 Warp coordinates: from_pos={from_pos} (exit from map {self.last_map_id}), to_pos={player_pos} (entry to map {map_id})")
355
+ self._detect_warp_connection(self.last_map_id, map_id,
356
+ from_pos, player_pos, timestamp)
357
+
358
+ # Try to resolve any unknown location names after adding connections
359
+ # Note: resolve_unknown_location_names() can be called with memory_reader from calling code
360
+ if self.resolve_unknown_location_names():
361
+ logger.info("Resolved unknown location names after area transition")
362
+ # Save will be handled by the calling code
363
+
364
+ # Update tracking variables for next iteration
365
+ if self.last_position != player_pos:
366
+ self.last_map_id = map_id
367
+ self.last_position = player_pos
368
+
369
+ def _detect_warp_tiles(self, map_data: List[List[Tuple]]) -> List[Tuple[int, int, str]]:
370
+ """Detect tiles that can be warps (doors, stairs, exits)"""
371
+ warp_tiles = []
372
+
373
+ for y, row in enumerate(map_data):
374
+ for x, tile in enumerate(row):
375
+ if len(tile) >= 2:
376
+ tile_id, behavior = tile[:2]
377
+
378
+ if hasattr(behavior, 'name'):
379
+ behavior_name = behavior.name
380
+ elif isinstance(behavior, int):
381
+ try:
382
+ behavior_enum = MetatileBehavior(behavior)
383
+ behavior_name = behavior_enum.name
384
+ except ValueError:
385
+ continue
386
+ else:
387
+ continue
388
+
389
+ # Classify warp types
390
+ warp_type = None
391
+ if "DOOR" in behavior_name:
392
+ warp_type = "door"
393
+ elif "STAIRS" in behavior_name:
394
+ warp_type = "stairs"
395
+ elif "WARP" in behavior_name:
396
+ warp_type = "warp"
397
+ elif x == 0 or x == len(row) - 1 or y == 0 or y == len(map_data) - 1:
398
+ # Edge tiles might be exits to other routes/areas
399
+ if behavior_name == "NORMAL" and tile[2] == 0: # collision == 0
400
+ warp_type = "exit"
401
+
402
+ if warp_type:
403
+ warp_tiles.append((x, y, warp_type))
404
+
405
+ return warp_tiles
406
+
407
+ def _calculate_boundaries(self, map_data: List[List[Tuple]]) -> Dict[str, int]:
408
+ """Calculate walkable boundaries of the map"""
409
+ height = len(map_data)
410
+ width = len(map_data[0]) if height > 0 else 0
411
+
412
+ return {
413
+ "north": 0,
414
+ "south": height - 1,
415
+ "west": 0,
416
+ "east": width - 1
417
+ }
418
+
419
+ def _detect_warp_connection(self, from_map_id: int, to_map_id: int,
420
+ from_pos: Optional[Tuple[int, int]],
421
+ to_pos: Tuple[int, int], timestamp: float):
422
+ """Detect and record warp connections between maps"""
423
+ if from_pos is None:
424
+ return
425
+
426
+ from_area = self.map_areas.get(from_map_id)
427
+ to_area = self.map_areas.get(to_map_id)
428
+
429
+ if not from_area or not to_area:
430
+ return
431
+
432
+ # Determine warp type and direction
433
+ warp_type = "route_transition" # default
434
+ direction = self._determine_warp_direction(from_area, to_area, from_pos, to_pos)
435
+
436
+ # Check if we were near a warp tile
437
+ near_warp = from_area.has_warp_at(from_pos[0], from_pos[1])
438
+ if near_warp:
439
+ warp_type = near_warp
440
+
441
+ # Create the connection
442
+ print(f"🔄 Creating warp connection: {from_pos} -> {to_pos} (maps {from_map_id} -> {to_map_id})")
443
+ connection = WarpConnection(
444
+ from_map_id=from_map_id,
445
+ to_map_id=to_map_id,
446
+ from_position=from_pos,
447
+ to_position=to_pos,
448
+ warp_type=warp_type,
449
+ direction=direction
450
+ )
451
+
452
+ # Check if this connection already exists
453
+ if not self._connection_exists(connection):
454
+ self.warp_connections.append(connection)
455
+ print(f"Added warp connection: {from_area.location_name} -> {to_area.location_name} "
456
+ f"({warp_type}, {direction})")
457
+
458
+ # Auto-add reverse connection for two-way warps
459
+ if warp_type in ["door", "stairs", "route_transition"]:
460
+ reverse = connection.get_reverse_connection()
461
+ if not self._connection_exists(reverse):
462
+ self.warp_connections.append(reverse)
463
+ logger.debug(f"Added reverse connection: {to_area.location_name} -> {from_area.location_name}")
464
+
465
+ def _determine_warp_direction(self, from_area: MapArea, to_area: MapArea,
466
+ from_pos: Tuple[int, int], to_pos: Tuple[int, int]) -> str:
467
+ """Determine the direction of movement for a warp"""
468
+ from_x, from_y = from_pos
469
+ to_x, to_y = to_pos
470
+
471
+ # Check if this is a vertical building transition (indoors <-> outdoors)
472
+ from_indoor = from_area.location_name and ("HOUSE" in from_area.location_name.upper() or "ROOM" in from_area.location_name.upper())
473
+ to_indoor = to_area.location_name and ("HOUSE" in to_area.location_name.upper() or "ROOM" in to_area.location_name.upper())
474
+
475
+ if from_indoor and not to_indoor:
476
+ return "down" # Exiting building
477
+ elif not from_indoor and to_indoor:
478
+ return "up" # Entering building
479
+
480
+ # For horizontal transitions, compare positions
481
+ from_bounds = from_area.get_map_bounds()
482
+ to_bounds = to_area.get_map_bounds()
483
+
484
+ # Simple heuristic based on position relative to map center
485
+ from_center_x = (from_bounds[2] - from_bounds[0]) // 2
486
+ from_center_y = (from_bounds[3] - from_bounds[1]) // 2
487
+
488
+ if from_x < from_center_x:
489
+ return "west"
490
+ elif from_x > from_center_x:
491
+ return "east"
492
+ elif from_y < from_center_y:
493
+ return "north"
494
+ else:
495
+ return "south"
496
+
497
+ def _connection_exists(self, connection: WarpConnection) -> bool:
498
+ """Check if a similar connection already exists"""
499
+ for existing in self.warp_connections:
500
+ if (existing.from_map_id == connection.from_map_id and
501
+ existing.to_map_id == connection.to_map_id and
502
+ existing.warp_type == connection.warp_type):
503
+ return True
504
+ return False
505
+
506
+ def _infer_overworld_coordinates(self, location_name: str, player_pos: Tuple[int, int]) -> Optional[Tuple[int, int]]:
507
+ """Infer overworld coordinates - should return None to keep coordinates unknown until discovered"""
508
+ # All coordinates start as unknown (?, ?) until actually discovered
509
+ # This ensures authentic exploration without pre-existing knowledge
510
+ return None
511
+
512
+ def update_overworld_coordinates(self, map_id: int, coords: Tuple[int, int]):
513
+ """Update overworld coordinates for a discovered area"""
514
+ if map_id in self.map_areas:
515
+ self.map_areas[map_id].overworld_coords = coords
516
+ logger.info(f"Updated coordinates for {self.map_areas[map_id].location_name}: {coords}")
517
+
518
+ def update_location_name(self, map_id: int, location_name: str):
519
+ """Update location name for an existing area"""
520
+ if map_id in self.map_areas and location_name and location_name.strip() and location_name != "Unknown":
521
+ area = self.map_areas[map_id]
522
+ if area.location_name == "Unknown" or not area.location_name:
523
+ logger.info(f"Updating location name for map {map_id:04X}: '{area.location_name}' -> '{location_name}'")
524
+ area.location_name = location_name
525
+ # Try to resolve other unknown names since we got new location info
526
+ self.resolve_unknown_location_names()
527
+ return True
528
+ return False
529
+
530
+ def resolve_unknown_location_names(self, memory_reader=None):
531
+ """Try to resolve 'Unknown' location names using the memory reader if available"""
532
+ resolved_count = 0
533
+
534
+ # If we have a memory reader, we can potentially resolve current location
535
+ if memory_reader is not None:
536
+ try:
537
+ current_location = memory_reader.read_location()
538
+ current_map_bank = memory_reader._read_u8(memory_reader.addresses.MAP_BANK)
539
+ current_map_number = memory_reader._read_u8(memory_reader.addresses.MAP_NUMBER)
540
+ current_map_id = (current_map_bank << 8) | current_map_number
541
+
542
+ # Update current map if it's unknown
543
+ if current_map_id in self.map_areas:
544
+ area = self.map_areas[current_map_id]
545
+ if area.location_name == "Unknown" and current_location and current_location.strip() and current_location != "Unknown":
546
+ old_name = area.location_name
547
+ area.location_name = current_location
548
+ logger.info(f"Resolved current location name for map {current_map_id:04X}: '{old_name}' -> '{area.location_name}'")
549
+ resolved_count += 1
550
+ except Exception as e:
551
+ logger.debug(f"Could not resolve current location: {e}")
552
+
553
+ if resolved_count > 0:
554
+ logger.info(f"Resolved {resolved_count} unknown location names")
555
+ return True
556
+ return False
557
+
558
+ def get_connected_areas(self, map_id: int) -> List[Tuple[int, str, str]]:
559
+ """Get all areas connected to the given map ID"""
560
+ connections = []
561
+ for conn in self.warp_connections:
562
+ if conn.from_map_id == map_id:
563
+ to_area = self.map_areas.get(conn.to_map_id)
564
+ if to_area:
565
+ connections.append((conn.to_map_id, to_area.location_name, conn.direction))
566
+ return connections
567
+
568
+ def get_world_map_layout(self) -> Dict[str, Any]:
569
+ """Generate a layout showing how all areas connect"""
570
+ layout = {
571
+ "areas": {},
572
+ "connections": []
573
+ }
574
+
575
+ # Add all known areas
576
+ for map_id, area in self.map_areas.items():
577
+ layout["areas"][f"{map_id:04X}"] = {
578
+ "name": area.location_name,
579
+ "position": area.player_last_position,
580
+ "bounds": area.boundaries,
581
+ "warp_count": len(area.warp_tiles),
582
+ "visited_count": area.visited_count
583
+ }
584
+
585
+ # Add all connections
586
+ for conn in self.warp_connections:
587
+ from_area = self.map_areas.get(conn.from_map_id)
588
+ to_area = self.map_areas.get(conn.to_map_id)
589
+ if from_area and to_area:
590
+ layout["connections"].append({
591
+ "from": f"{conn.from_map_id:04X}",
592
+ "to": f"{conn.to_map_id:04X}",
593
+ "from_name": from_area.location_name,
594
+ "to_name": to_area.location_name,
595
+ "warp_type": conn.warp_type,
596
+ "direction": conn.direction,
597
+ "from_pos": conn.from_position,
598
+ "to_pos": conn.to_position
599
+ })
600
+
601
+ return layout
602
+
603
+ def get_player_position_for_location(self, location_name: str) -> Optional[Tuple[int, int]]:
604
+ """Get the last known player position for a specific location.
605
+
606
+ Returns:
607
+ Tuple of (x, y) coordinates or None if not found or invalid
608
+ """
609
+ # Find the map area with this location name
610
+ for area in self.map_areas.values():
611
+ if area.location_name and location_name and area.location_name.lower() == location_name.lower():
612
+ if hasattr(area, 'player_last_position') and area.player_last_position:
613
+ px, py = area.player_last_position
614
+ # Validate the position
615
+ if px >= 0 and px < 1000 and py >= 0 and py < 1000 and px != 0xFFFF and py != 0xFFFF:
616
+ return (px, py)
617
+ break
618
+ return None
619
+
620
+ def get_location_connections(self, location_name=None):
621
+ """Get connections for a specific location or all locations.
622
+
623
+ Args:
624
+ location_name: Optional location name to get connections for.
625
+ If None, returns all location connections.
626
+
627
+ Returns:
628
+ If location_name provided: List of (to_location, from_coords, to_coords) tuples
629
+ Otherwise: Dict mapping location names to connection lists
630
+ """
631
+ location_connections = {}
632
+
633
+ # Process each warp connection
634
+ for conn in self.warp_connections:
635
+ from_area = self.map_areas.get(conn.from_map_id)
636
+ to_area = self.map_areas.get(conn.to_map_id)
637
+
638
+ if from_area and to_area:
639
+ from_location = from_area.location_name
640
+ to_location = to_area.location_name
641
+
642
+ # Add forward connection
643
+ if from_location not in location_connections:
644
+ location_connections[from_location] = []
645
+
646
+ # Check if connection already exists
647
+ exists = False
648
+ for existing in location_connections[from_location]:
649
+ if existing[0] == to_location:
650
+ exists = True
651
+ break
652
+
653
+ if not exists:
654
+ # Use the actual last positions from map areas, not the warp spawn point
655
+ # This gives more useful information about where transitions happen
656
+ from_pos = list(conn.from_position) if conn.from_position else [1, 1]
657
+ to_pos = list(to_area.player_last_position) if to_area.player_last_position else list(conn.to_position)
658
+
659
+ location_connections[from_location].append([
660
+ to_location,
661
+ from_pos,
662
+ to_pos
663
+ ])
664
+
665
+ # If specific location requested, return just its connections (case-insensitive)
666
+ if location_name:
667
+ # Try to find the location with case-insensitive matching
668
+ for loc_name, connections in location_connections.items():
669
+ if loc_name and loc_name.lower() == location_name.lower():
670
+ return connections
671
+ return []
672
+
673
+ return location_connections
674
+
675
+ def get_location_grid(self, location_name: str, simplified: bool = True) -> Dict[Tuple[int, int], str]:
676
+ """Get a simplified grid representation of a location for display.
677
+
678
+ Args:
679
+ location_name: Name of the location to get grid for
680
+ simplified: If True, return simplified symbols (., #, D, etc.), otherwise raw tile data
681
+
682
+ Returns:
683
+ Dictionary mapping (x, y) coordinates to tile symbols
684
+ """
685
+ # Find the map area with this location name (case-insensitive)
686
+ map_area = None
687
+ for area in self.map_areas.values():
688
+ if area.location_name and location_name and area.location_name.lower() == location_name.lower():
689
+ map_area = area
690
+ break
691
+
692
+ if not map_area:
693
+ # Debug: print available locations
694
+ logger.debug(f"Could not find map area for '{location_name}'")
695
+ logger.debug(f"Available locations: {[a.location_name for a in self.map_areas.values() if a.location_name][:5]}")
696
+ return {}
697
+
698
+ if not map_area.map_data:
699
+ logger.debug(f"Map area found for '{location_name}' but has no map_data")
700
+ return {}
701
+
702
+ grid = {}
703
+
704
+ # If we have explored bounds, use them to extract only the explored portion
705
+ if hasattr(map_area, 'explored_bounds'):
706
+ bounds = map_area.explored_bounds
707
+ for y in range(bounds['min_y'], bounds['max_y'] + 1):
708
+ for x in range(bounds['min_x'], bounds['max_x'] + 1):
709
+ if y < len(map_area.map_data) and x < len(map_area.map_data[0]):
710
+ tile = map_area.map_data[y][x]
711
+ if tile is not None: # Only include explored tiles
712
+ # Adjust coordinates to be relative to the explored area
713
+ rel_x = x - bounds['min_x']
714
+ rel_y = y - bounds['min_y']
715
+
716
+ if simplified:
717
+ # Convert to simplified symbol
718
+ symbol = self._tile_to_symbol(tile)
719
+ if symbol is not None: # Only add if it's a valid tile
720
+ # Debug specific problematic position
721
+ if rel_x == 2 and rel_y == 1:
722
+ logger.debug(f"Tile at rel(2,1) from grid[{y}][{x}]: {tile[:3] if len(tile) >= 3 else tile} -> symbol '{symbol}'")
723
+ grid[(rel_x, rel_y)] = symbol
724
+ else:
725
+ grid[(rel_x, rel_y)] = tile
726
+
727
+ # Add '?' for unexplored but adjacent tiles
728
+ if simplified:
729
+ # Find all positions adjacent to explored walkable tiles
730
+ to_check = set()
731
+ for (x, y), symbol in list(grid.items()):
732
+ # Only add ? next to truly walkable tiles, not walls
733
+ if symbol in ['.', 'D', 'S', '^', '~', 's', 'I', # Walkable terrain
734
+ '→', '←', '↑', '↓', '↗', '↖', '↘', '↙']: # Ledges
735
+ # Check all 4 adjacent positions (not diagonal)
736
+ for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
737
+ adj_pos = (x + dx, y + dy)
738
+ if adj_pos not in grid:
739
+ to_check.add(adj_pos)
740
+
741
+ # Add '?' for these unexplored adjacent positions
742
+ for pos in to_check:
743
+ grid[pos] = '?'
744
+
745
+ return grid
746
+
747
+ # Fallback: old logic for non-accumulated maps
748
+ # Check if we should extract a focused area from the stored map
749
+ extract_bounds = getattr(map_area, '_display_extract_bounds', None)
750
+ if extract_bounds:
751
+ extract_start_x, extract_start_y, display_size = extract_bounds
752
+ # Extract only the specified area
753
+ for y in range(display_size):
754
+ for x in range(display_size):
755
+ stored_y = extract_start_y + y
756
+ stored_x = extract_start_x + x
757
+ if (stored_y < len(map_area.map_data) and
758
+ stored_x < len(map_area.map_data[stored_y])):
759
+ tile = map_area.map_data[stored_y][stored_x]
760
+ if tile and len(tile) >= 3:
761
+ tile_id, behavior, collision = tile[:3]
762
+
763
+ if simplified:
764
+ # Use the centralized tile_to_symbol function
765
+ symbol = self._tile_to_symbol(tile)
766
+ if symbol is not None: # Only add if it's a valid tile
767
+ grid[(x, y)] = symbol
768
+ else:
769
+ # Return raw tile data
770
+ grid[(x, y)] = tile
771
+ else:
772
+ # Use full stored map (fallback for old behavior)
773
+ for y, row in enumerate(map_area.map_data):
774
+ for x, tile in enumerate(row):
775
+ if tile and len(tile) >= 3:
776
+ tile_id, behavior, collision = tile[:3]
777
+
778
+ if simplified:
779
+ # Use the centralized tile_to_symbol function
780
+ symbol = self._tile_to_symbol(tile)
781
+ if symbol is not None: # Only add if it's a valid tile
782
+ grid[(x, y)] = symbol
783
+ else:
784
+ # Return raw tile data
785
+ grid[(x, y)] = tile
786
+
787
+ return grid
788
+
789
+ def get_all_location_grids(self, simplified: bool = True) -> Dict[str, Dict[Tuple[int, int], str]]:
790
+ """Get grids for all known locations.
791
+
792
+ Returns:
793
+ Dictionary mapping location names to their grids
794
+ """
795
+ all_grids = {}
796
+ for area in self.map_areas.values():
797
+ if area.location_name and area.map_data:
798
+ all_grids[area.location_name] = self.get_location_grid(area.location_name, simplified)
799
+ return all_grids
800
+
801
+ def save_to_file(self):
802
+ """Save stitching data to JSON file"""
803
+ try:
804
+ data = {
805
+ "map_areas": {},
806
+ "location_connections": {}
807
+ }
808
+
809
+ # Convert map areas to serializable format
810
+ for map_id, area in self.map_areas.items():
811
+ # Trim null rows from map_data before saving
812
+ if area.map_data:
813
+ trimmed_map_data, trim_offsets = self._trim_null_rows(area.map_data)
814
+ else:
815
+ trimmed_map_data, trim_offsets = [], {}
816
+
817
+ # Save only essential data
818
+ area_data = {
819
+ "map_id": area.map_id,
820
+ "location_name": area.location_name,
821
+ "map_data": trimmed_map_data,
822
+ "player_last_position": area.player_last_position
823
+ }
824
+
825
+ # Save trim offsets if we trimmed the data
826
+ if trim_offsets:
827
+ area_data["trim_offsets"] = trim_offsets
828
+
829
+ # Save additional attributes for map stitching
830
+ if hasattr(area, 'explored_bounds'):
831
+ area_data["explored_bounds"] = area.explored_bounds
832
+ if hasattr(area, 'origin_offset'):
833
+ area_data["origin_offset"] = area.origin_offset
834
+ data["map_areas"][str(map_id)] = area_data
835
+
836
+ # Generate location_connections from warp_connections
837
+ # MapStitcher is the single source of truth for connections
838
+ data["location_connections"] = self.get_location_connections()
839
+ logger.debug(f"Saved {len(data['location_connections'])} location connections from {len(self.warp_connections)} warp connections")
840
+
841
+ with open(self.save_file, 'w') as f:
842
+ # Save in minified format to reduce file size
843
+ json.dump(data, f, separators=(',', ':'))
844
+
845
+ logger.debug(f"Saved map stitching data to {self.save_file}")
846
+
847
+ except Exception as e:
848
+ logger.error(f"Failed to save map stitching data: {e}")
849
+
850
+ def load_from_file(self):
851
+ """Load stitching data from JSON file"""
852
+ if not self.save_file.exists():
853
+ return
854
+
855
+ # Check if file is empty
856
+ if self.save_file.stat().st_size == 0:
857
+ logger.debug(f"Map stitcher file {self.save_file} is empty, starting fresh")
858
+ return
859
+
860
+ try:
861
+ with open(self.save_file, 'r') as f:
862
+ data = json.load(f)
863
+
864
+ # Add loaded data to existing map areas (accumulate knowledge)
865
+ # Restore map areas (with map_data for world map display)
866
+ for map_id_str, area_data in data.get("map_areas", {}).items():
867
+ map_id = int(map_id_str)
868
+
869
+ # Skip map 0 during loading as well (cleanup old data)
870
+ if map_id == 0:
871
+ logger.debug(f"Skipping load of map 0 (startup state) during file load")
872
+ continue
873
+
874
+ # Try to resolve location name if it's Unknown or missing
875
+ location_name = area_data.get("location_name")
876
+ if not location_name or location_name == "Unknown":
877
+ # Import and use the location mapping
878
+ try:
879
+ map_enum = MapLocation(map_id)
880
+ location_name = map_enum.name.replace('_', ' ').title()
881
+ logger.info(f"Resolved location name for map {map_id:04X} during load: {location_name}")
882
+ except ValueError:
883
+ # Fallback for unknown map IDs
884
+ location_name = f"Map_{map_id:04X}"
885
+ logger.debug(f"Unknown map ID {map_id:04X} during load, using fallback name")
886
+
887
+ # Reconstruct full map data from trimmed version
888
+ trimmed_data = area_data.get("map_data", [])
889
+ trim_offsets = area_data.get("trim_offsets", {})
890
+
891
+ if trim_offsets and trim_offsets.get('compacted'):
892
+ # New compacted format - reconstruct from tile list
893
+ row_offset = trim_offsets.get('row_offset', 0)
894
+ col_offset = trim_offsets.get('col_offset', 0)
895
+ original_height = trim_offsets.get('original_height', 100)
896
+ original_width = trim_offsets.get('original_width', 100)
897
+
898
+ # Create full-sized map data array
899
+ full_map_data = [[None for _ in range(original_width)] for _ in range(original_height)]
900
+
901
+ # Restore tiles from compacted format
902
+ if isinstance(trimmed_data, list):
903
+ # New list format: [[rel_row, rel_col, tile], ...]
904
+ for item in trimmed_data:
905
+ if len(item) >= 3:
906
+ rel_row, rel_col, tile = item[0], item[1], item[2]
907
+ actual_row = row_offset + rel_row
908
+ actual_col = col_offset + rel_col
909
+ if actual_row < original_height and actual_col < original_width:
910
+ full_map_data[actual_row][actual_col] = tile
911
+ elif isinstance(trimmed_data, dict) and 'tiles' in trimmed_data:
912
+ # Old dict format (backward compatibility)
913
+ for pos_key, tile in trimmed_data['tiles'].items():
914
+ rel_row, rel_col = map(int, pos_key.split(','))
915
+ actual_row = row_offset + rel_row
916
+ actual_col = col_offset + rel_col
917
+ if actual_row < original_height and actual_col < original_width:
918
+ full_map_data[actual_row][actual_col] = tile
919
+
920
+ map_data = full_map_data
921
+ elif trimmed_data and trim_offsets:
922
+ # Old trimmed format (backward compatibility)
923
+ row_offset = trim_offsets.get('row_offset', 0)
924
+ col_offset = trim_offsets.get('col_offset', 0)
925
+ original_height = trim_offsets.get('original_height', len(trimmed_data) + row_offset)
926
+ original_width = trim_offsets.get('original_width', 100)
927
+
928
+ # Create full-sized map data array
929
+ full_map_data = [[None for _ in range(original_width)] for _ in range(original_height)]
930
+
931
+ # Place trimmed data back at correct position
932
+ for i, row in enumerate(trimmed_data):
933
+ for j, tile in enumerate(row):
934
+ if tile is not None:
935
+ full_map_data[row_offset + i][col_offset + j] = tile
936
+
937
+ map_data = full_map_data
938
+ else:
939
+ # No trim offsets, use data as-is (backward compatibility)
940
+ map_data = trimmed_data
941
+
942
+ # Validate and clean player position when loading
943
+ player_pos_data = area_data.get("player_last_position", [0, 0])
944
+ if player_pos_data:
945
+ px, py = player_pos_data[0], player_pos_data[1] if len(player_pos_data) > 1 else 0
946
+ # Clean up invalid positions (65535 = 0xFFFF is an error value)
947
+ if px < 0 or px > 1000 or py < 0 or py > 1000 or px == 0xFFFF or py == 0xFFFF:
948
+ logger.warning(f"Cleaning invalid player position {player_pos_data} for map {map_id:04X}")
949
+ player_pos_data = [0, 0] # Reset to origin
950
+ else:
951
+ player_pos_data = [0, 0]
952
+
953
+ area = MapArea(
954
+ map_id=area_data["map_id"],
955
+ location_name=location_name,
956
+ map_data=map_data,
957
+ player_last_position=tuple(player_pos_data),
958
+ warp_tiles=[], # Deprecated - not needed
959
+ boundaries={"north": 0, "south": 10, "west": 0, "east": 10}, # Default boundaries
960
+ visited_count=1, # Default
961
+ first_seen=0, # Default
962
+ last_seen=0, # Default
963
+ overworld_coords=None # Not needed
964
+ )
965
+ # Restore additional stitching attributes if present
966
+ if "explored_bounds" in area_data:
967
+ area.explored_bounds = area_data["explored_bounds"]
968
+ # When loading trimmed data, adjust explored_bounds to match
969
+ # Since we trimmed null rows/columns, the bounds are now relative to the trimmed data
970
+ if area.map_data:
971
+ # The trimmed data starts at (0,0), so adjust bounds accordingly
972
+ actual_height = len(area.map_data)
973
+ actual_width = max(len(row) for row in area.map_data) if area.map_data else 0
974
+ # Keep the existing explored_bounds as they track the original coordinate space
975
+ # The map_data is now compact but explored_bounds maintains the relationship
976
+ else:
977
+ # Initialize explored bounds from map data if not present
978
+ if area.map_data:
979
+ min_x, max_x = 100, 0
980
+ min_y, max_y = 100, 0
981
+ for y, row in enumerate(area.map_data):
982
+ for x, tile in enumerate(row):
983
+ if tile is not None:
984
+ min_x = min(min_x, x)
985
+ max_x = max(max_x, x)
986
+ min_y = min(min_y, y)
987
+ max_y = max(max_y, y)
988
+ if min_x <= max_x:
989
+ area.explored_bounds = {
990
+ 'min_x': min_x, 'max_x': max_x,
991
+ 'min_y': min_y, 'max_y': max_y
992
+ }
993
+
994
+ if "origin_offset" in area_data:
995
+ area.origin_offset = area_data["origin_offset"]
996
+ else:
997
+ # Initialize origin offset based on player position
998
+ if area.player_last_position:
999
+ # Assume player was at center of initial explored area
1000
+ area.origin_offset = {'x': 50 - area.player_last_position[0],
1001
+ 'y': 50 - area.player_last_position[1]}
1002
+ self.map_areas[map_id] = area
1003
+ # Debug: log if map_data was loaded
1004
+ if area.map_data:
1005
+ logger.debug(f"Loaded map_data for {location_name}: {len(area.map_data)}x{len(area.map_data[0]) if area.map_data else 0}")
1006
+
1007
+ # Reconstruct warp_connections from location_connections
1008
+ location_connections = data.get("location_connections", {})
1009
+
1010
+ # Clear existing warp connections to avoid duplicates
1011
+ self.warp_connections = []
1012
+
1013
+ # Convert location_connections back to warp_connections
1014
+ for from_location, connections in location_connections.items():
1015
+ # Find the map_id for this location
1016
+ from_map_id = None
1017
+ for map_id, area in self.map_areas.items():
1018
+ if area.location_name == from_location:
1019
+ from_map_id = map_id
1020
+ break
1021
+
1022
+ if from_map_id is None:
1023
+ continue
1024
+
1025
+ for conn_data in connections:
1026
+ to_location = conn_data[0]
1027
+ from_pos = tuple(conn_data[1]) if len(conn_data) > 1 else (0, 0)
1028
+ to_pos = tuple(conn_data[2]) if len(conn_data) > 2 else (0, 0)
1029
+
1030
+ # Find the map_id for the destination
1031
+ to_map_id = None
1032
+ for map_id, area in self.map_areas.items():
1033
+ if area.location_name == to_location:
1034
+ to_map_id = map_id
1035
+ break
1036
+
1037
+ if to_map_id is None:
1038
+ continue
1039
+
1040
+ # Create warp connection
1041
+ warp_conn = WarpConnection(
1042
+ from_map_id=from_map_id,
1043
+ to_map_id=to_map_id,
1044
+ from_position=from_pos,
1045
+ to_position=to_pos,
1046
+ warp_type="stairs", # Default type
1047
+ direction=None
1048
+ )
1049
+ self.warp_connections.append(warp_conn)
1050
+
1051
+ logger.info(f"Reconstructed {len(self.warp_connections)} warp connections from {len(location_connections)} location connections")
1052
+
1053
+ logger.info(f"Loaded {len(self.map_areas)} areas and {len(self.warp_connections)} connections")
1054
+
1055
+ # Try to resolve any "Unknown" location names
1056
+ if self.resolve_unknown_location_names():
1057
+ # Save the updated names
1058
+ self.save_to_file()
1059
+
1060
+ except Exception as e:
1061
+ logger.error(f"Failed to load map stitching data: {e}")
1062
+
1063
+ def get_stats(self) -> Dict[str, Any]:
1064
+ """Get statistics about the stitched world map"""
1065
+ indoor_areas = sum(1 for area in self.map_areas.values()
1066
+ if area.location_name and ("HOUSE" in area.location_name.upper() or "ROOM" in area.location_name.upper()))
1067
+ outdoor_areas = len(self.map_areas) - indoor_areas
1068
+
1069
+ warp_types = {}
1070
+ for conn in self.warp_connections:
1071
+ warp_types[conn.warp_type] = warp_types.get(conn.warp_type, 0) + 1
1072
+
1073
+ return {
1074
+ "total_areas": len(self.map_areas),
1075
+ "indoor_areas": indoor_areas,
1076
+ "outdoor_areas": outdoor_areas,
1077
+ "total_connections": len(self.warp_connections),
1078
+ "warp_types": warp_types,
1079
+ "most_visited": max(self.map_areas.values(), key=lambda a: a.visited_count).location_name if self.map_areas else None
1080
+ }
1081
+
1082
+ def generate_world_map_grid(self, current_map_id: Optional[int] = None) -> Dict[str, Any]:
1083
+ """Generate a world map grid showing discovered areas and connections"""
1084
+ # Define world map bounds (rough Pokemon Emerald overworld size)
1085
+ map_width = 50
1086
+ map_height = 35
1087
+
1088
+ # Initialize empty grid
1089
+ grid = [['.' for _ in range(map_width)] for _ in range(map_height)]
1090
+ area_labels = {}
1091
+
1092
+ # Place discovered areas on the grid
1093
+ for map_id, area in self.map_areas.items():
1094
+ coords = area.overworld_coords
1095
+ if coords is None:
1096
+ continue # Skip areas without known coordinates
1097
+
1098
+ x, y = coords
1099
+ if 0 <= x < map_width and 0 <= y < map_height:
1100
+ # Determine symbol based on area type
1101
+ name = area.location_name.upper() if area.location_name else "UNKNOWN"
1102
+ if any(keyword in name for keyword in ["HOUSE", "CENTER", "MART", "GYM", "ROOM"]):
1103
+ symbol = "H" # Houses/buildings
1104
+ elif "ROUTE" in name:
1105
+ symbol = "R" # Routes
1106
+ elif any(keyword in name for keyword in ["TOWN", "CITY"]):
1107
+ symbol = "T" # Towns/cities
1108
+ else:
1109
+ symbol = "?" # Unknown/other
1110
+
1111
+ # Mark current player location
1112
+ if map_id == current_map_id:
1113
+ symbol = "P" # Player
1114
+
1115
+ grid[y][x] = symbol
1116
+
1117
+ # Store area name for reference
1118
+ area_labels[f"{x},{y}"] = area.location_name
1119
+
1120
+ # Add connection lines between areas
1121
+ for conn in self.warp_connections:
1122
+ from_area = self.map_areas.get(conn.from_map_id)
1123
+ to_area = self.map_areas.get(conn.to_map_id)
1124
+
1125
+ if (from_area and to_area and
1126
+ from_area.overworld_coords and to_area.overworld_coords):
1127
+
1128
+ from_x, from_y = from_area.overworld_coords
1129
+ to_x, to_y = to_area.overworld_coords
1130
+
1131
+ # Draw simple connection line (just mark endpoints for now)
1132
+ # In a more sophisticated version, we could draw actual paths
1133
+ if (0 <= from_x < map_width and 0 <= from_y < map_height and
1134
+ 0 <= to_x < map_width and 0 <= to_y < map_height):
1135
+
1136
+ # Mark connection endpoints if they're empty
1137
+ if grid[from_y][from_x] == '.':
1138
+ grid[from_y][from_x] = "+"
1139
+ if grid[to_y][to_x] == '.':
1140
+ grid[to_y][to_x] = "+"
1141
+
1142
+ return {
1143
+ "grid": grid,
1144
+ "width": map_width,
1145
+ "height": map_height,
1146
+ "area_labels": area_labels,
1147
+ "legend": {
1148
+ "P": "Current Player Location",
1149
+ "T": "Town/City",
1150
+ "R": "Route",
1151
+ "H": "House/Building",
1152
+ "+": "Connection Point",
1153
+ ".": "Unexplored",
1154
+ "?": "Other Area"
1155
+ }
1156
+ }
1157
+
1158
+ def _should_trim_edge(self, tiles, is_row=True):
1159
+ """Check if an edge (row or column) should be trimmed.
1160
+ An edge should be trimmed if it's all walls (#) with no meaningful content."""
1161
+ # Count non-wall tiles
1162
+ non_wall_count = 0
1163
+ for tile in tiles:
1164
+ if tile and tile not in ['#', ' ', None]:
1165
+ non_wall_count += 1
1166
+ # Trim if it's all walls or mostly walls with no content
1167
+ return non_wall_count == 0
1168
+
1169
+ def _trim_null_rows(self, map_data: List[List]) -> Tuple[List[List], Dict[str, int]]:
1170
+ """Trim rows that are entirely null/None from map data to reduce file size.
1171
+
1172
+ Returns a tuple of (trimmed_data, trim_offsets) where trim_offsets contains
1173
+ the offsets needed to reconstruct original positions.
1174
+ """
1175
+ if not map_data:
1176
+ return [], {}
1177
+
1178
+ # Find bounds of actual data
1179
+ start_row = None
1180
+ end_row = None
1181
+ start_col = None
1182
+ end_col = None
1183
+
1184
+ # Find row bounds
1185
+ for i, row in enumerate(map_data):
1186
+ if row and any(tile is not None for tile in row):
1187
+ if start_row is None:
1188
+ start_row = i
1189
+ end_row = i
1190
+
1191
+ if start_row is None:
1192
+ # All data is null
1193
+ return [], {}
1194
+
1195
+ # Find column bounds across all rows
1196
+ for row in map_data[start_row:end_row + 1]:
1197
+ if row:
1198
+ for j, tile in enumerate(row):
1199
+ if tile is not None:
1200
+ if start_col is None or j < start_col:
1201
+ start_col = j
1202
+ if end_col is None or j > end_col:
1203
+ end_col = j
1204
+
1205
+ if start_col is None:
1206
+ return [], {}
1207
+
1208
+ # Create compacted data - use a list of [row, col, tile] to save space
1209
+ # This eliminates ALL null-only rows while preserving position information
1210
+ tiles_list = []
1211
+
1212
+ # Store only non-null tiles with their positions
1213
+ for i in range(start_row, end_row + 1):
1214
+ if map_data[i]:
1215
+ for j in range(start_col, end_col + 1):
1216
+ if j < len(map_data[i]) and map_data[i][j] is not None:
1217
+ # Store as [relative_row, relative_col, tile_data]
1218
+ # This is more compact than dict with string keys
1219
+ rel_row = i - start_row
1220
+ rel_col = j - start_col
1221
+ tiles_list.append([rel_row, rel_col, map_data[i][j]])
1222
+
1223
+ trim_offsets = {
1224
+ 'row_offset': start_row,
1225
+ 'col_offset': start_col,
1226
+ 'original_height': len(map_data),
1227
+ 'original_width': max(len(row) for row in map_data) if map_data else 0,
1228
+ 'compacted': True # Flag to indicate new format
1229
+ }
1230
+
1231
+ return tiles_list, trim_offsets
1232
+
1233
+ def generate_location_map_display(self, location_name: str, player_pos: Tuple[int, int] = None,
1234
+ npcs: List[Dict] = None, connections: List[Dict] = None) -> List[str]:
1235
+ """Generate a detailed map display for a specific location.
1236
+
1237
+ Args:
1238
+ location_name: Name of the location to display
1239
+ player_pos: Current player position (x, y)
1240
+ npcs: List of NPC positions and data
1241
+ connections: List of location connections
1242
+
1243
+ Returns:
1244
+ List of display lines ready for formatting
1245
+ """
1246
+ lines = []
1247
+
1248
+ # Get stored map data for this location
1249
+ location_grid = self.get_location_grid(location_name, simplified=True)
1250
+
1251
+ if not location_grid:
1252
+ # No map data available - return empty to trigger memory fallback
1253
+ return []
1254
+
1255
+ # For accumulated maps, show the full explored area
1256
+ # Get the dimensions of the explored area
1257
+ max_x = max(x for x, y in location_grid.keys()) if location_grid else 0
1258
+ max_y = max(y for x, y in location_grid.keys()) if location_grid else 0
1259
+ min_x = min(x for x, y in location_grid.keys()) if location_grid else 0
1260
+ min_y = min(y for x, y in location_grid.keys()) if location_grid else 0
1261
+
1262
+ explored_width = max_x - min_x + 1
1263
+ explored_height = max_y - min_y + 1
1264
+
1265
+ # Show the full accumulated map (up to reasonable size)
1266
+ # Don't try to focus on player for accumulated maps
1267
+ if explored_width <= 30 and explored_height <= 30:
1268
+ # Show the entire accumulated map
1269
+ display_radius = max(explored_width, explored_height) // 2
1270
+ display_size = max(explored_width, explored_height)
1271
+ else:
1272
+ # Very large area, limit to 30x30 for readability
1273
+ display_radius = 15
1274
+ display_size = 30
1275
+
1276
+ display_center = display_radius # Player at center
1277
+
1278
+ # For accumulated maps, just use the entire grid without focusing
1279
+ # This shows the full explored area
1280
+ all_positions = list(location_grid.keys())
1281
+
1282
+ # Find player position in the grid if available
1283
+ local_player_pos = None
1284
+ if player_pos:
1285
+ # Validate player position first
1286
+ px, py = player_pos
1287
+ if px >= 0 and px < 1000 and py >= 0 and py < 1000 and px != 0xFFFF and py != 0xFFFF:
1288
+ # Find the stored map area to get coordinate conversion info
1289
+ map_area = None
1290
+ for area in self.map_areas.values():
1291
+ if area.location_name and location_name and area.location_name.lower() == location_name.lower():
1292
+ map_area = area
1293
+ break
1294
+
1295
+ if map_area:
1296
+ # Use the stored player position from the map area if available
1297
+ if hasattr(map_area, 'player_last_position') and map_area.player_last_position:
1298
+ last_px, last_py = map_area.player_last_position
1299
+ # Validate the stored position
1300
+ if last_px >= 0 and last_px < 1000 and last_py >= 0 and last_py < 1000 and last_px != 0xFFFF and last_py != 0xFFFF:
1301
+ player_pos = map_area.player_last_position
1302
+ px, py = player_pos
1303
+
1304
+ if hasattr(map_area, 'origin_offset') and map_area.origin_offset:
1305
+ # Convert player world coordinates to grid-relative coordinates
1306
+ offset_x = map_area.origin_offset.get('x', 0)
1307
+ offset_y = map_area.origin_offset.get('y', 0)
1308
+
1309
+ # Calculate player's position relative to the explored bounds
1310
+ grid_player_x = px + offset_x
1311
+ grid_player_y = py + offset_y
1312
+
1313
+ # Convert to relative coordinates in the location_grid
1314
+ if hasattr(map_area, 'explored_bounds'):
1315
+ bounds = map_area.explored_bounds
1316
+ rel_x = grid_player_x - bounds['min_x']
1317
+ rel_y = grid_player_y - bounds['min_y']
1318
+
1319
+ # Check if player is within the displayed area
1320
+ if 0 <= rel_x <= (max_x - min_x) and 0 <= rel_y <= (max_y - min_y):
1321
+ local_player_pos = (rel_x, rel_y)
1322
+ logger.debug(f"Player at relative position {local_player_pos} in {location_name}")
1323
+ else:
1324
+ logger.debug(f"Player at {player_pos} is outside displayed area of {location_name}")
1325
+
1326
+ if not all_positions:
1327
+ return []
1328
+
1329
+ min_x = min(pos[0] for pos in all_positions)
1330
+ max_x = max(pos[0] for pos in all_positions)
1331
+ min_y = min(pos[1] for pos in all_positions)
1332
+ max_y = max(pos[1] for pos in all_positions)
1333
+
1334
+ # Minimal trimming - only remove completely empty space
1335
+ # Don't trim '?' as those are unexplored areas we want to show
1336
+ # Don't aggressively trim walls as they show room boundaries
1337
+
1338
+ # Only trim rows that are completely empty (all spaces/None)
1339
+ while min_y < max_y:
1340
+ row_tiles = [location_grid.get((x, min_y), ' ') for x in range(min_x, max_x + 1)]
1341
+ # Keep the row if it has ANY content (including ? and #)
1342
+ if any(t not in [' ', None] for t in row_tiles):
1343
+ break
1344
+ min_y += 1
1345
+
1346
+ # Check bottom rows - only trim completely empty
1347
+ while max_y > min_y:
1348
+ row_tiles = [location_grid.get((x, max_y), ' ') for x in range(min_x, max_x + 1)]
1349
+ if any(t not in [' ', None] for t in row_tiles):
1350
+ break
1351
+ max_y -= 1
1352
+
1353
+ # Check left columns - only trim completely empty
1354
+ while min_x < max_x:
1355
+ col_tiles = [location_grid.get((min_x, y), ' ') for y in range(min_y, max_y + 1)]
1356
+ if any(t not in [' ', None] for t in col_tiles):
1357
+ break
1358
+ min_x += 1
1359
+
1360
+ # Check right columns - only trim completely empty
1361
+ while max_x > min_x:
1362
+ col_tiles = [location_grid.get((max_x, y), ' ') for y in range(min_y, max_y + 1)]
1363
+ if any(t not in [' ', None] for t in col_tiles):
1364
+ break
1365
+ max_x -= 1
1366
+
1367
+ # Build portal positions from connections
1368
+ portal_positions = {}
1369
+
1370
+ lines.append(f"\n--- MAP: {location_name.upper()} ---")
1371
+
1372
+ # Create the map display
1373
+ for y in range(min_y, max_y + 1):
1374
+ row = ""
1375
+ for x in range(min_x, max_x + 1):
1376
+ # Check if this is an edge position
1377
+ is_edge = (x == min_x or x == max_x or y == min_y or y == max_y)
1378
+
1379
+ # Check for NPCs at this position
1380
+ npc_at_pos = None
1381
+ if npcs:
1382
+ for npc in npcs:
1383
+ npc_x = npc.get('current_x', npc.get('x'))
1384
+ npc_y = npc.get('current_y', npc.get('y'))
1385
+ if npc_x == x and npc_y == y:
1386
+ npc_at_pos = npc
1387
+ break
1388
+
1389
+ if local_player_pos and (x, y) == local_player_pos:
1390
+ row += "P"
1391
+ elif npc_at_pos:
1392
+ row += "N"
1393
+ elif (x, y) in location_grid:
1394
+ tile = location_grid[(x, y)]
1395
+ # Check for portal markers at edges
1396
+ if is_edge and tile == '.' and connections:
1397
+ portal_added = False
1398
+ for conn in connections:
1399
+ direction = conn.get('direction', '').lower()
1400
+ conn_name = conn.get('name', '')
1401
+ if direction and conn_name and conn_name not in ['Unknown', 'None', '']:
1402
+ if direction == 'east' and x == max_x:
1403
+ row += "→"
1404
+ portal_positions[(x, y)] = conn_name
1405
+ portal_added = True
1406
+ break
1407
+ elif direction == 'west' and x == min_x:
1408
+ row += "←"
1409
+ portal_positions[(x, y)] = conn_name
1410
+ portal_added = True
1411
+ break
1412
+ elif direction == 'north' and y == min_y:
1413
+ row += "↑"
1414
+ portal_positions[(x, y)] = conn_name
1415
+ portal_added = True
1416
+ break
1417
+ elif direction == 'south' and y == max_y:
1418
+ row += "↓"
1419
+ portal_positions[(x, y)] = conn_name
1420
+ portal_added = True
1421
+ break
1422
+
1423
+ if not portal_added:
1424
+ row += tile
1425
+ else:
1426
+ row += tile
1427
+ else:
1428
+ # Position not in grid - just show as space
1429
+ # The grid already has '?' symbols where needed from get_location_grid
1430
+ row += " "
1431
+
1432
+ # Add spacing between characters for square aspect ratio
1433
+ # Most terminals have characters ~2x taller than wide, so spacing helps
1434
+ spaced_row = " ".join(row)
1435
+ lines.append(spaced_row)
1436
+
1437
+ # Add legend
1438
+ legend_lines = ["", "Legend:"]
1439
+ legend_lines.append(" Movement: P=Player")
1440
+ if npcs:
1441
+ legend_lines.append(" N=NPC/Trainer")
1442
+
1443
+ # Check what terrain symbols are visible
1444
+ visible_symbols = set(location_grid.values())
1445
+
1446
+ terrain_items = []
1447
+ symbol_meanings = {
1448
+ ".": ".=Walkable path",
1449
+ "#": "#=Wall/Blocked",
1450
+ "~": "~=Tall grass",
1451
+ "^": "^=Grass",
1452
+ "W": "W=Water",
1453
+ "I": "I=Ice",
1454
+ "s": "s=Sand",
1455
+ "D": "D=Door",
1456
+ "S": "S=Stairs/Ladder",
1457
+ "C": "C=Computer/PC",
1458
+ "→": "→=Ledge (jump east)",
1459
+ "←": "←=Ledge (jump west)",
1460
+ "↑": "↑=Ledge (jump north)",
1461
+ "↓": "↓=Ledge (jump south)",
1462
+ "↗": "↗=Ledge (jump NE)",
1463
+ "↖": "↖=Ledge (jump NW)",
1464
+ "↘": "↘=Ledge (jump SE)",
1465
+ "↙": "↙=Ledge (jump SW)",
1466
+ "L": "L=Ledge",
1467
+ "T": "T=TV",
1468
+ "?": "?=Unknown"
1469
+ }
1470
+
1471
+ for symbol, meaning in symbol_meanings.items():
1472
+ if symbol in visible_symbols:
1473
+ terrain_items.append(meaning)
1474
+
1475
+ if terrain_items:
1476
+ legend_lines.append(f" Terrain: {', '.join(terrain_items)}")
1477
+
1478
+ # Add portal markers to legend if any
1479
+ if portal_positions:
1480
+ unique_portals = {}
1481
+ for pos, dest in portal_positions.items():
1482
+ x, y = pos
1483
+ if x == min_x:
1484
+ unique_portals["←"] = dest
1485
+ elif x == max_x:
1486
+ unique_portals["→"] = dest
1487
+ elif y == min_y:
1488
+ unique_portals["↑"] = dest
1489
+ elif y == max_y:
1490
+ unique_portals["↓"] = dest
1491
+
1492
+ if unique_portals:
1493
+ portal_items = []
1494
+ for symbol, dest in unique_portals.items():
1495
+ portal_items.append(f"{symbol}={dest}")
1496
+ legend_lines.append(f" Exits: {', '.join(portal_items)}")
1497
+
1498
+ lines.extend(legend_lines)
1499
+
1500
+ # Add explicit portal connections with coordinates
1501
+ if connections:
1502
+ lines.append("")
1503
+ lines.append("Portal Connections:")
1504
+ for conn in connections:
1505
+ to_location = conn.get('to', 'Unknown')
1506
+ from_pos = conn.get('from_pos', [])
1507
+ to_pos = conn.get('to_pos', [])
1508
+
1509
+ if from_pos and to_pos and len(from_pos) >= 2 and len(to_pos) >= 2:
1510
+ lines.append(f" {location_name} ({from_pos[0]},{from_pos[1]}) → {to_location} ({to_pos[0]},{to_pos[1]})")
1511
+ elif from_pos and len(from_pos) >= 2:
1512
+ lines.append(f" {location_name} ({from_pos[0]},{from_pos[1]}) → {to_location}")
1513
+ else:
1514
+ lines.append(f" → {to_location}")
1515
+
1516
+ return lines
1517
+
1518
+ def _tile_to_symbol(self, tile) -> str:
1519
+ """Convert a tile tuple to a simplified symbol for display."""
1520
+ if tile is None:
1521
+ # This will be handled specially - unexplored areas next to walkable will show ?
1522
+ return None # Mark as unexplored for special handling
1523
+
1524
+ if len(tile) < 3:
1525
+ return None # Invalid tile - unexplored
1526
+
1527
+ tile_id, behavior, collision = tile[:3]
1528
+
1529
+ # tile_id 1023 (0x3FF) means out-of-bounds/unloaded area
1530
+ # These are trees/boundaries at the edge of maps - show as walls
1531
+ if tile_id == 1023:
1532
+ return '#' # Display as wall/blocked
1533
+
1534
+ # Get behavior value
1535
+ if hasattr(behavior, 'value'):
1536
+ behavior_val = behavior.value
1537
+ else:
1538
+ behavior_val = behavior
1539
+
1540
+ # Check behavior first for special terrain (even if impassable)
1541
+ # Grass types (from MetatileBehavior enum)
1542
+ if behavior_val == 2: # TALL_GRASS
1543
+ return '~' # Tall grass (encounters)
1544
+ elif behavior_val == 3: # LONG_GRASS
1545
+ return '^' # Long grass
1546
+ elif behavior_val == 7: # SHORT_GRASS
1547
+ return '^' # Short grass
1548
+ elif behavior_val == 36: # ASHGRASS
1549
+ return '^' # Ash grass
1550
+
1551
+ # Water types
1552
+ elif behavior_val in [16, 17, 18, 19, 20, 21, 22, 23, 24, 26]: # Various water types
1553
+ return 'W' # Water
1554
+
1555
+ # Ice
1556
+ elif behavior_val in [32, 38, 39]: # ICE, THIN_ICE, CRACKED_ICE
1557
+ return 'I' # Ice
1558
+
1559
+ # Sand
1560
+ elif behavior_val in [6, 33]: # DEEP_SAND, SAND
1561
+ return 's' # Sand
1562
+
1563
+ # Doors and warps
1564
+ elif behavior_val == 96: # NON_ANIMATED_DOOR
1565
+ return 'D' # Door
1566
+ elif behavior_val == 105: # ANIMATED_DOOR
1567
+ return 'D' # Door
1568
+ elif behavior_val in [98, 99, 100, 101]: # Arrow warps
1569
+ return 'D' # Warp/Door
1570
+ elif behavior_val == 97: # LADDER
1571
+ return 'S' # Stairs/Ladder
1572
+ elif behavior_val in [106, 107]: # Escalators
1573
+ return 'S' # Stairs
1574
+
1575
+ # PC and other interactables
1576
+ elif behavior_val in [131, 197]: # PC, PLAYER_ROOM_PC_ON
1577
+ return 'C' # Computer/PC (changed from 'P' to avoid conflict with Player)
1578
+ elif behavior_val == 134: # TELEVISION
1579
+ return 'T' # TV
1580
+
1581
+ # Ledges/Jumps with directional arrows
1582
+ elif behavior_val == 56: # JUMP_EAST
1583
+ return '→' # Ledge east
1584
+ elif behavior_val == 57: # JUMP_WEST
1585
+ return '←' # Ledge west
1586
+ elif behavior_val == 58: # JUMP_NORTH
1587
+ return '↑' # Ledge north
1588
+ elif behavior_val == 59: # JUMP_SOUTH
1589
+ return '↓' # Ledge south
1590
+ elif behavior_val == 60: # JUMP_NORTHEAST
1591
+ return '↗' # Ledge northeast
1592
+ elif behavior_val == 61: # JUMP_NORTHWEST
1593
+ return '↖' # Ledge northwest
1594
+ elif behavior_val == 62: # JUMP_SOUTHEAST
1595
+ return '↘' # Ledge southeast
1596
+ elif behavior_val == 63: # JUMP_SOUTHWEST
1597
+ return '↙' # Ledge southwest
1598
+
1599
+ # Now check collision for basic terrain
1600
+ elif collision == 1: # Impassable
1601
+ return '#' # Wall
1602
+ elif collision == 0: # Walkable
1603
+ return '.' # Floor
1604
+ elif collision == 3: # Ledge/special
1605
+ return 'L' # Ledge
1606
+ elif collision == 4: # Water/surf
1607
+ return 'W' # Water
1608
+ else:
1609
+ return '?' # Unknown
1610
+
1611
+ def _is_explorable_edge(self, x: int, y: int, location_grid: Dict[Tuple[int, int], str]) -> bool:
1612
+ """Check if an unexplored coordinate is worth exploring (adjacent to walkable tiles)."""
1613
+ # Check all 4 adjacent tiles
1614
+ for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
1615
+ adj_x, adj_y = x + dx, y + dy
1616
+ if (adj_x, adj_y) in location_grid:
1617
+ tile = location_grid[(adj_x, adj_y)]
1618
+ # If adjacent to walkable tile, this is explorable
1619
+ # Include all walkable terrain types and ledges
1620
+ if tile in ['.', 'D', 'S', '^', '~', 's', 'I', # Floor, doors, stairs, grass, sand, ice
1621
+ '→', '←', '↑', '↓', '↗', '↖', '↘', '↙']: # Ledges in all directions
1622
+ return True
1623
+ return False
1624
+
1625
+ def format_world_map_display(self, current_map_id: Optional[int] = None, max_width: int = 50) -> str:
1626
+ """Format world map for display in agent context"""
1627
+ world_map = self.generate_world_map_grid(current_map_id)
1628
+ grid = world_map["grid"]
1629
+ labels = world_map["area_labels"]
1630
+ legend = world_map["legend"]
1631
+
1632
+ lines = []
1633
+ lines.append("=== WORLD MAP ===")
1634
+ lines.append("")
1635
+
1636
+ # Show grid with coordinates
1637
+ for y, row in enumerate(grid):
1638
+ row_str = ""
1639
+ for x, cell in enumerate(row):
1640
+ row_str += cell + " "
1641
+ lines.append(f"{y:2d}: {row_str}")
1642
+
1643
+ # Add coordinate header at bottom
1644
+ header = " "
1645
+ for x in range(0, len(grid[0]), 5): # Show every 5th coordinate
1646
+ header += f"{x:2d} "
1647
+ lines.append("")
1648
+ lines.append(header)
1649
+
1650
+ # Add legend
1651
+ lines.append("")
1652
+ lines.append("Legend:")
1653
+ for symbol, meaning in legend.items():
1654
+ lines.append(f" {symbol} = {meaning}")
1655
+
1656
+ # Add discovered area list
1657
+ if labels:
1658
+ lines.append("")
1659
+ lines.append("Discovered Areas:")
1660
+ sorted_areas = sorted(labels.items(), key=lambda x: x[1])
1661
+ for coord, name in sorted_areas[:10]: # Show first 10
1662
+ lines.append(f" {coord}: {name}")
1663
+ if len(sorted_areas) > 10:
1664
+ lines.append(f" ... and {len(sorted_areas) - 10} more")
1665
+
1666
+ return "\n".join(lines)
1667
+
1668
+ def save_to_checkpoint(self, checkpoint_data: dict):
1669
+ """Save map stitching data to checkpoint data structure"""
1670
+ try:
1671
+ map_stitcher_data = {
1672
+ "map_areas": {},
1673
+ "warp_connections": [],
1674
+ "location_connections": {}
1675
+ }
1676
+
1677
+ # Convert map areas to serializable format (without map_data)
1678
+ for map_id, area in self.map_areas.items():
1679
+ area_data = {
1680
+ "map_id": area.map_id,
1681
+ "location_name": area.location_name,
1682
+ "player_last_position": area.player_last_position,
1683
+ "warp_tiles": area.warp_tiles,
1684
+ "boundaries": area.boundaries,
1685
+ "visited_count": area.visited_count,
1686
+ "first_seen": area.first_seen,
1687
+ "last_seen": area.last_seen,
1688
+ "overworld_coords": area.overworld_coords
1689
+ }
1690
+ # print( Saving area {map_id} with overworld_coords = {area.overworld_coords}")
1691
+ map_stitcher_data["map_areas"][str(map_id)] = area_data
1692
+
1693
+ # Convert connections to serializable format
1694
+ for conn in self.warp_connections:
1695
+ map_stitcher_data["warp_connections"].append(asdict(conn))
1696
+
1697
+ # Save location connections from state_formatter
1698
+ try:
1699
+ if hasattr(state_formatter, 'LOCATION_CONNECTIONS'):
1700
+ map_stitcher_data["location_connections"] = state_formatter.LOCATION_CONNECTIONS
1701
+ logger.debug(f"Saved {len(state_formatter.LOCATION_CONNECTIONS)} location connections to checkpoint")
1702
+ except ImportError:
1703
+ logger.debug("Could not import state_formatter for location connections in checkpoint")
1704
+
1705
+ checkpoint_data["map_stitcher"] = map_stitcher_data
1706
+ logger.debug(f"Saved {len(self.map_areas)} areas and {len(self.warp_connections)} connections to checkpoint")
1707
+
1708
+ except Exception as e:
1709
+ logger.error(f"Failed to save map stitcher to checkpoint: {e}")
1710
+
1711
+ def load_from_checkpoint(self, checkpoint_data: dict):
1712
+ """Load map stitching data from checkpoint data structure"""
1713
+ try:
1714
+ map_stitcher_data = checkpoint_data.get("map_stitcher")
1715
+ if not map_stitcher_data:
1716
+ return
1717
+
1718
+ # Clear existing data
1719
+ self.map_areas.clear()
1720
+ self.warp_connections.clear()
1721
+
1722
+ # Restore map areas (without map_data)
1723
+ for map_id_str, area_data in map_stitcher_data.get("map_areas", {}).items():
1724
+ map_id = int(map_id_str)
1725
+ area = MapArea(
1726
+ map_id=area_data["map_id"],
1727
+ location_name=area_data["location_name"],
1728
+ map_data=[], # Will be populated when area is revisited
1729
+ player_last_position=tuple(area_data["player_last_position"]),
1730
+ warp_tiles=[tuple(wt) for wt in area_data["warp_tiles"]],
1731
+ boundaries=area_data["boundaries"],
1732
+ visited_count=area_data["visited_count"],
1733
+ first_seen=area_data["first_seen"],
1734
+ last_seen=area_data["last_seen"],
1735
+ overworld_coords=tuple(area_data["overworld_coords"]) if area_data.get("overworld_coords") else None
1736
+ )
1737
+ self.map_areas[map_id] = area
1738
+
1739
+ # Restore connections
1740
+ for conn_data in map_stitcher_data.get("warp_connections", []):
1741
+ conn = WarpConnection(
1742
+ from_map_id=conn_data["from_map_id"],
1743
+ to_map_id=conn_data["to_map_id"],
1744
+ from_position=tuple(conn_data["from_position"]),
1745
+ to_position=tuple(conn_data["to_position"]),
1746
+ warp_type=conn_data["warp_type"],
1747
+ direction=conn_data["direction"]
1748
+ )
1749
+ self.warp_connections.append(conn)
1750
+
1751
+ # Restore location connections to state_formatter
1752
+ location_connections = map_stitcher_data.get("location_connections", {})
1753
+ if location_connections:
1754
+ try:
1755
+ state_formatter.LOCATION_CONNECTIONS = location_connections
1756
+ logger.info(f"Loaded {len(location_connections)} location connections from checkpoint")
1757
+ except ImportError:
1758
+ logger.debug("Could not import state_formatter for location connections from checkpoint")
1759
+
1760
+ logger.info(f"Loaded {len(self.map_areas)} areas and {len(self.warp_connections)} connections from checkpoint")
1761
+
1762
+ except Exception as e:
1763
+ logger.error(f"Failed to load map stitcher from checkpoint: {e}")