synth-ai 0.2.13.dev1__py3-none-any.whl → 0.2.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of synth-ai might be problematic. Click here for more details.
- examples/multi_step/configs/README_verilog_rl.md +77 -0
- examples/multi_step/configs/VERILOG_REWARDS.md +90 -0
- examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +183 -0
- examples/multi_step/configs/crafter_eval_synth_qwen4b.toml +35 -0
- examples/multi_step/configs/crafter_eval_text_only_groq_qwen32b.toml +36 -0
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +17 -5
- examples/multi_step/configs/crafter_synth_backend.md +40 -0
- examples/multi_step/configs/verilog_eval_groq_qwen32b.toml +31 -0
- examples/multi_step/configs/verilog_eval_synth_qwen8b.toml +33 -0
- examples/multi_step/configs/verilog_rl_lora.toml +190 -0
- examples/multi_step/judges/crafter_backend_judge.py +220 -0
- examples/multi_step/judges/verilog_backend_judge.py +234 -0
- examples/multi_step/readme.md +48 -0
- examples/multi_step/verilog_rl_lora.md +218 -0
- examples/qwen_coder/configs/coder_lora_30b.toml +1 -1
- examples/sft/evaluate.py +2 -0
- examples/sft/generate_traces.py +2 -0
- examples/swe/task_app/grpo_swe_mini.py +56 -26
- examples/swe/task_app/hosted/rollout.py +42 -0
- examples/swe/task_app/hosted/test_service.py +5 -6
- examples/task_apps/IMAGE_ONLY_EVAL_QUICKSTART.md +258 -0
- examples/task_apps/TESTING.md +275 -0
- examples/task_apps/__init__.py +0 -0
- examples/task_apps/crafter/CREATE_SFT_DATASET.md +273 -0
- examples/task_apps/crafter/EVAL_IMAGE_ONLY_RESULTS.md +152 -0
- examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +174 -0
- examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +268 -0
- examples/task_apps/crafter/QUERY_EXAMPLES.md +203 -0
- examples/task_apps/crafter/README_IMAGE_ONLY_EVAL.md +316 -0
- examples/task_apps/crafter/__init__.py +0 -0
- examples/task_apps/crafter/eval_image_only_gpt4o.toml +28 -0
- examples/task_apps/crafter/eval_text_only_groq_llama.toml +36 -0
- examples/task_apps/crafter/filter_sft_dataset.toml +16 -0
- examples/task_apps/crafter/task_app/__init__.py +5 -0
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter.py +324 -21
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter_task_app.py +1 -1
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/environment.py +10 -0
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/policy.py +76 -7
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/react_agent.py +17 -2
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/openai_client.py +25 -3
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/policy_routes.py +77 -4
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/rollout.py +117 -9
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_service.py +5 -6
- examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +218 -0
- examples/task_apps/dev/pokemon_emerald/__init__.py +2 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/README.md +811 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/__init__.py +120 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/action.py +160 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/memory.py +155 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/perception.py +69 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/planning.py +96 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/simple.py +1502 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/system_prompt.py +4 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/grab_map.py +68 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/manual.py +216 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/__init__.py +35 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emerald_utils.py +631 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emulator.py +1544 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/enums.py +1428 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/memory_reader.py +4848 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/types.py +41 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/utils.py +298 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pyproject.toml +95 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/run.py +204 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/__init__.py +0 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/app.py +2152 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/client.py +429 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/frame_server.py +155 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/README.md +78 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/__init__.py +0 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/run_tests.py +122 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_direct.py +76 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_prompts.py +413 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_battle_state_formatting.py +204 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection.py +133 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection_comprehensive.py +229 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_direct_agent_emulator.py +300 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_fps_adjustment_pytest.py +205 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_direct.py +200 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_transition.py +284 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_map_ground_truth_comparison.py +468 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_memory_map.py +575 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_server_map_validation.py +311 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_torchic_state.py +259 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/__init__.py +0 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/anticheat.py +372 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/checkpoint.py +296 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/error_handler.py +275 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/get_local_ip.py +22 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/helpers.py +44 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/llm_logger.py +514 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_formatter.py +415 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher.py +1763 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher_singleton.py +33 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_trimmer.py +106 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_visualizer.py +334 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/ocr_dialogue.py +1020 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/recording.py +188 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/state_formatter.py +1481 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/vlm.py +862 -0
- examples/task_apps/dev/pokemon_emerald/modal_app.py +114 -0
- examples/task_apps/dev/pokemon_emerald/task_app/README.md +81 -0
- examples/task_apps/dev/pokemon_emerald/task_app/__init__.py +6 -0
- examples/task_apps/dev/pokemon_emerald/task_app/pokemon_emerald.py +685 -0
- examples/task_apps/enron/__init__.py +1 -0
- examples/task_apps/enron/eval_groq_qwen32.toml +16 -0
- examples/task_apps/enron/filter_sft.toml +5 -0
- examples/task_apps/enron/task_app/README.md +14 -0
- examples/task_apps/enron/task_app/__init__.py +1 -0
- examples/task_apps/enron/task_app/grpo_enron.py +906 -0
- examples/task_apps/enron/task_app/grpo_enron_task_app.py +146 -0
- examples/task_apps/enron/tests/__init__.py +4 -0
- examples/task_apps/enron/tests/conftest.py +115 -0
- examples/task_apps/enron/tests/integration/__init__.py +4 -0
- examples/task_apps/enron/tests/integration/test_enron_eval.py +179 -0
- examples/task_apps/enron/tests/integration/test_enron_rollout.py +135 -0
- examples/task_apps/enron/tests/unit/__init__.py +4 -0
- examples/task_apps/enron/tests/unit/test_enron_environment.py +126 -0
- examples/task_apps/math/__init__.py +0 -0
- examples/{rl/task_app → task_apps/math}/math_single_step.py +19 -10
- examples/task_apps/pokemon_battle/__init__.py +2 -0
- examples/task_apps/pokemon_battle/modal_app.py +104 -0
- examples/task_apps/pokemon_battle/task_app/README.md +68 -0
- examples/task_apps/pokemon_battle/task_app/__init__.py +6 -0
- examples/task_apps/pokemon_battle/task_app/pokemon_showdown.py +932 -0
- examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_COMPLETE.md +283 -0
- examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_STATUS.md +155 -0
- examples/task_apps/pokemon_red/README.md +357 -0
- examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +415 -0
- examples/task_apps/pokemon_red/__init__.py +3 -0
- examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +29 -0
- examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +225 -0
- examples/task_apps/pokemon_red/pallet_town_rl_config.toml +75 -0
- examples/task_apps/pokemon_red/task_app.py +799 -0
- examples/task_apps/pokemon_red/test_pallet_town_rewards.py +193 -0
- examples/task_apps/sokoban/README.md +307 -0
- examples/task_apps/sokoban/__init__.py +3 -0
- examples/task_apps/sokoban/eval_groq_qwen32.toml +16 -0
- examples/task_apps/sokoban/eval_openai_gpt5.toml +16 -0
- examples/task_apps/sokoban/filter_sft.toml +5 -0
- examples/task_apps/sokoban/task_app.py +1058 -0
- examples/task_apps/sokoban/tests/__init__.py +4 -0
- examples/task_apps/sokoban/tests/conftest.py +113 -0
- examples/task_apps/sokoban/tests/integration/__init__.py +4 -0
- examples/task_apps/sokoban/tests/integration/test_sokoban_eval.py +57 -0
- examples/task_apps/sokoban/tests/integration/test_sokoban_rollout.py +198 -0
- examples/task_apps/sokoban/tests/unit/__init__.py +4 -0
- examples/task_apps/sokoban/tests/unit/test_sokoban_environment.py +114 -0
- examples/task_apps/verilog/__init__.py +1 -0
- examples/task_apps/verilog/eval_groq_qwen32b.toml +24 -0
- examples/task_apps/verilog/filter_sft.toml +5 -0
- examples/task_apps/verilog/task_app/README.md +12 -0
- examples/task_apps/verilog/task_app/__init__.py +1 -0
- examples/task_apps/verilog/task_app/grpo_verilog.py +1166 -0
- examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +145 -0
- examples/task_apps/verilog/tests/__init__.py +4 -0
- examples/task_apps/verilog/tests/conftest.py +115 -0
- examples/task_apps/verilog/tests/integration/__init__.py +4 -0
- examples/task_apps/verilog/tests/integration/test_verilog_eval.py +181 -0
- examples/task_apps/verilog/tests/integration/test_verilog_rollout.py +55 -0
- examples/task_apps/verilog/tests/unit/__init__.py +4 -0
- examples/task_apps/verilog/tests/unit/test_verilog_scoring.py +118 -0
- examples/vlm/crafter_openai_vlm_agent.py +4 -4
- examples/vlm/run_crafter_vlm_benchmark.py +4 -4
- examples/warming_up_to_rl/groq_test.py +2 -0
- examples/warming_up_to_rl/run_local_rollout.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_modal.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_parallel.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_traced.py +2 -0
- examples/warming_up_to_rl/run_rollout_remote.py +2 -0
- examples/workflows/__init__.py +0 -0
- examples/workflows/math_rl/__init__.py +0 -0
- examples/workflows/math_rl/download_dataset.py +80 -0
- synth_ai/__init__.py +2 -2
- synth_ai/api/models/supported.py +1 -0
- synth_ai/api/train/builders.py +25 -11
- synth_ai/api/train/cli.py +12 -6
- synth_ai/api/train/configs/__init__.py +10 -10
- synth_ai/api/train/configs/rl.py +5 -4
- synth_ai/api/train/configs/sft.py +4 -3
- synth_ai/api/train/env_resolver.py +5 -2
- synth_ai/api/train/supported_algos.py +10 -5
- synth_ai/api/train/utils.py +7 -4
- synth_ai/cli/__init__.py +48 -59
- synth_ai/cli/_modal_wrapper.py +3 -2
- synth_ai/cli/_storage.py +4 -3
- synth_ai/cli/_validate_task_app.py +11 -0
- synth_ai/cli/balance.py +4 -3
- synth_ai/cli/calc.py +2 -2
- synth_ai/cli/demo.py +14 -7
- synth_ai/cli/legacy_root_backup.py +1 -1
- synth_ai/cli/recent.py +1 -1
- synth_ai/cli/rl_demo.py +8 -7
- synth_ai/cli/root.py +0 -97
- synth_ai/cli/status.py +1 -1
- synth_ai/cli/task_apps.py +1922 -190
- synth_ai/cli/traces.py +1 -1
- synth_ai/cli/tui.py +57 -0
- synth_ai/cli/turso.py +1 -1
- synth_ai/cli/watch.py +1 -1
- synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +29 -17
- synth_ai/environments/examples/crafter_classic/environment.py +1 -1
- synth_ai/environments/examples/enron/engine.py +7 -2
- synth_ai/environments/examples/enron/environment.py +68 -0
- synth_ai/environments/examples/red/engine.py +27 -0
- synth_ai/environments/examples/red/engine_helpers/memory_map.py +7 -0
- synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_progression.py +477 -0
- synth_ai/environments/examples/red/engine_helpers/state_extraction.py +32 -0
- synth_ai/environments/examples/red/environment.py +60 -0
- synth_ai/environments/examples/sokoban/taskset.py +116 -0
- synth_ai/environments/examples/verilog/engine.py +104 -12
- synth_ai/evals/client.py +58 -61
- synth_ai/jobs/client.py +16 -4
- synth_ai/judge_schemas.py +9 -9
- synth_ai/py.typed +0 -0
- synth_ai/task/__init__.py +24 -5
- synth_ai/task/apps/__init__.py +1 -0
- synth_ai/task/config.py +257 -0
- synth_ai/task/contracts.py +138 -39
- synth_ai/task/proxy.py +48 -56
- synth_ai/task/rubrics/__init__.py +56 -0
- synth_ai/task/rubrics/loaders.py +152 -0
- synth_ai/task/rubrics/models.py +57 -0
- synth_ai/task/rubrics/scoring.py +116 -0
- synth_ai/{rubrics/validators.py → task/rubrics/strict.py} +53 -30
- synth_ai/task/server.py +8 -7
- synth_ai/task/trace_correlation_helpers.py +315 -0
- synth_ai/task/validators.py +413 -6
- synth_ai/tracing_v3/abstractions.py +3 -3
- synth_ai/tracing_v3/decorators.py +7 -3
- synth_ai/tracing_v3/llm_call_record_helpers.py +5 -5
- synth_ai/tracing_v3/replica_sync.py +4 -4
- synth_ai/tracing_v3/serialization.py +5 -5
- synth_ai/tracing_v3/session_tracer.py +16 -6
- synth_ai/tracing_v3/storage/base.py +29 -29
- synth_ai/tracing_v3/storage/config.py +3 -3
- synth_ai/tracing_v3/trace_utils.py +317 -0
- synth_ai/tracing_v3/turso/daemon.py +8 -7
- synth_ai/tracing_v3/turso/native_manager.py +66 -43
- synth_ai/tracing_v3/utils.py +3 -3
- synth_ai/tui/__init__.py +5 -0
- synth_ai/tui/__main__.py +13 -0
- synth_ai/tui/cli/__init__.py +1 -0
- synth_ai/tui/cli/query_experiments.py +164 -0
- synth_ai/tui/cli/query_experiments_v3.py +164 -0
- synth_ai/tui/dashboard.py +906 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/METADATA +4 -1
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/RECORD +278 -126
- examples/agora_ex/README_MoE.md +0 -224
- examples/agora_ex/__init__.py +0 -7
- examples/agora_ex/agora_ex.py +0 -65
- examples/agora_ex/agora_ex_task_app.py +0 -590
- examples/agora_ex/configs/rl_lora_qwen3_moe_2xh200.toml +0 -121
- examples/agora_ex/reward_fn_grpo-human.py +0 -129
- examples/agora_ex/system_prompt_CURRENT.md +0 -63
- examples/agora_ex/task_app/agora_ex_task_app.py +0 -590
- examples/agora_ex/task_app/reward_fn_grpo-human.py +0 -129
- examples/agora_ex/task_app/system_prompt_CURRENT.md +0 -63
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +0 -62
- synth_ai/rubrics/__init__.py +0 -22
- synth_ai/task/rubrics.py +0 -219
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/README.md +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/README.md +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/branching.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/environment_routes.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/app.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/shared.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/tools.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/hosted_app.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/main.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/registry.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/volume.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_agents.py +0 -0
- /examples/{rl/task_app → task_apps/math}/README.md +0 -0
- /examples/{rl/task_app → task_apps/math}/math_task_app.py +0 -0
- /examples/{rl → workflows/math_rl}/configs/eval_base_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/eval_rl_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen17.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/rl_from_ft_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/run_eval.py +0 -0
- /examples/{rl → workflows/math_rl}/run_rl_and_save.py +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/top_level.txt +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}")
|