"""LiveAdapter 离线测试:用 SDK 真实模型伪造 Turn,验证正式世界适配层。 正式世界的事件名/字段和模拟器不同,这层翻译错了策略就是瞎的,但线上没法回归, 所以在这里离线锁住。 运行:.venv/bin/python tests/test_deploy_adapter.py """ import sys from pathlib import Path from uuid import UUID sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from arena_hero import Turn from arena_hero.actions import CancelMoveAction, CommandPlan, DropBeaconAction from arena_hero.enums import BeaconStatus, CoreState, PlayerStatus, UnitType from arena_hero.models import ( ChampionBeacon, CoreView, PlayerState, ResolutionEvent, TerrainView, UnitView, ) from deploy import LiveAdapter from strategies.heuristic import HeuristicStrategy, make_default_genes W_ID = UUID("00000000-0000-0000-0000-0000000000a1") C_ID = UUID("00000000-0000-0000-0000-0000000000c1") E_ID = UUID("00000000-0000-0000-0000-0000000000e1") def make_turn(*, tick=10, worker_pos=(5, 5), core_pos=(4, 5), events=(), beacon_status=None, carrier_id=None, obstacles=(), resources=()): objects = [ UnitView(kind="UNIT", id=W_ID, controlled=True, position=worker_pos, hp=2, unit_type=UnitType.WORKER, cargo=0), CoreView(kind="CORE", id=C_ID, controlled=True, owner_username="tester", position=core_pos, hp=5, shield=5, state=CoreState.NORMAL), ] if obstacles: objects.append(TerrainView(kind="OBSTACLE", positions=tuple(obstacles))) if resources: objects.append(TerrainView(kind="RESOURCE", positions=tuple(resources))) state = PlayerState( status=PlayerStatus.ACTIVE, respawn_at_tick=None, resources=7, population=1, # rules v0.14 / SDK 0.2.9:population_tier/upkeep_next_tick 已删除 champion_beacon=ChampionBeacon(position=(0, 0), status=beacon_status, carrier_id=carrier_id), objects=tuple(objects), events=tuple(events), ) return Turn(tick=tick, state=state, submitter=lambda *a, **k: None) def mk_adapter(): return LiveAdapter(HeuristicStrategy(genes=make_default_genes(), bounds=None)) # ---------------------------------------------------------------------- def test_move_failed_becomes_move_blocked(): """UNIT_MOVE_FAILED(actor_id) + 上一 tick 提交的方向 → MOVE_BLOCKED。""" a = mk_adapter() a._sent_dirs = {W_ID: "RIGHT"} # 模拟上一 tick 提交过 MOVE RIGHT ev = ResolutionEvent(event_id=UUID(int=1), tick=9, event_type="UNIT_MOVE_FAILED", reason_code="MOVE_BLOCKED_TERRAIN", actor_id=W_ID, target_id=None, position=(5, 5), values=None) out = a.translate_events(make_turn(events=[ev])) assert len(out) == 1, out assert out[0] == {"type": "MOVE_BLOCKED", "obj_id": W_ID, "direction": "RIGHT", "reason": "MOVE_BLOCKED_TERRAIN", "source_type": "UNIT_MOVE_FAILED", "blocked_cell": (6, 5)}, out[0] def test_move_failed_without_known_direction_is_skipped(): """方向不明时必须跳过,否则会把错误的格子学成障碍。""" a = mk_adapter() ev = ResolutionEvent(event_id=UUID(int=2), tick=9, event_type="UNIT_MOVE_FAILED", reason_code="MOVE_CONTESTED", actor_id=W_ID, target_id=None, position=(5, 5), values=None) out = a.translate_events(make_turn(events=[ev])) assert out == [], out def test_move_succeeded_becomes_moved(): a = mk_adapter() ev = ResolutionEvent(event_id=UUID(int=3), tick=9, event_type="UNIT_MOVE_SUCCEEDED", reason_code=None, actor_id=W_ID, target_id=None, position=(6, 5), values=None) out = a.translate_events(make_turn(events=[ev])) assert out[0]["type"] == "MOVED" and out[0]["obj_id"] == W_ID, out def test_core_move_failed_uses_migration_direction(): a = mk_adapter() a._core_dir = "UP" ev = ResolutionEvent(event_id=UUID(int=4), tick=9, event_type="CORE_MOVE_FAILED", reason_code="CORE_DESTINATION_TERRAIN_BLOCKED", actor_id=C_ID, target_id=None, position=(4, 5), values=None) out = a.translate_events(make_turn(events=[ev])) assert out[0] == {"type": "MOVE_BLOCKED", "obj_id": C_ID, "direction": "UP", "reason": "CORE_DESTINATION_TERRAIN_BLOCKED", "source_type": "CORE_MOVE_FAILED", "blocked_cell": (4, 4)}, out def test_carries_beacon_derived_from_carrier_id(): a = mk_adapter() turn = make_turn(beacon_status=BeaconStatus.CARRIED, carrier_id=W_ID) obs = a.build_observation(turn) assert obs.units[0]["carries_beacon"] is True turn2 = make_turn(beacon_status=BeaconStatus.CARRIED, carrier_id=E_ID) obs2 = a.build_observation(turn2) assert obs2.units[0]["carries_beacon"] is False def test_beacon_status_compares_as_plain_string(): """策略里是 obs.beacon["status"] == "GROUND",StrEnum 必须能直接比。""" a = mk_adapter() obs = a.build_observation(make_turn(beacon_status=BeaconStatus.GROUND)) assert obs.beacon["status"] == "GROUND" obs2 = a.build_observation(make_turn()) assert obs2.beacon["status"] == "UNKNOWN" def test_v014_observation_and_status_have_no_upkeep_fields(): """Removed v0.13 maintenance fields must not leak into local adapters.""" a = mk_adapter() turn = make_turn() obs = a.build_observation(turn) assert not hasattr(obs, "population_tier") assert not hasattr(obs, "upkeep_next_tick") status = a.build_status(turn.tick, obs, {"units": {}, "core": None}) assert "upkeep_next" not in status def test_events_come_from_current_turn_not_stale_copy(): """turn.events 已经是上一 Tick 的结果,不能再延迟一拍。""" a = mk_adapter() a._sent_dirs = {W_ID: "RIGHT"} ev = ResolutionEvent(event_id=UUID(int=5), tick=9, event_type="UNIT_MOVE_FAILED", reason_code="X", actor_id=W_ID, target_id=None, position=(5, 5), values=None) obs = a.build_observation(make_turn(events=[ev])) assert obs.prev_events and obs.prev_events[0]["type"] == "MOVE_BLOCKED" def test_memory_learns_obstacle_end_to_end(): """整条链路:连续两次撞同一格 → 临时避让(temp_blocked), 不学成永久障碍——MOVE_BLOCKED 无法区分地形与单位占位, 永久化会产生假障碍把可达资源围死。""" a = mk_adapter() for _ in range(2): a._sent_dirs = {W_ID: "RIGHT"} ev = ResolutionEvent(event_id=UUID(int=6), tick=9, event_type="UNIT_MOVE_FAILED", reason_code="MOVE_BLOCKED_TERRAIN", actor_id=W_ID, target_id=None, position=(5, 5), values=None) obs = a.build_observation(make_turn(events=[ev])) a.strategy.decide(obs) assert (6, 5) in a.strategy.mem.temp_blocked, a.strategy.mem.temp_blocked assert (6, 5) not in a.strategy.mem.obstacles, a.strategy.mem.obstacles def test_dead_own_unit_is_classified_from_previous_observation(): """The dead Unit is absent from the new state but remains ours for logging.""" from types import SimpleNamespace a = mk_adapter() a._known_own_ids = {W_ID, C_ID} messages = [] a._log_key_event = lambda ev, et, ids, obs=None: messages.append( a._fmt_key_event(ev, et, ids, obs)) death = ResolutionEvent( event_id=UUID(int=7), tick=10, event_type="UNIT_DAMAGED", reason_code="ATTACK", actor_id=None, target_id=W_ID, position=(5, 5), values={"damage": 1, "hp": 0}) empty_obs = SimpleNamespace(units=[], core={"uid": C_ID}, enemies=[]) a.translate_events(make_turn(events=[death]), obs=empty_obs) assert messages and "我方单位阵亡" in messages[0], messages assert a.recent_events[-1]["target_is_own"] is True def _run_all(): tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] passed = 0 for t in tests: try: t() print(f"PASS {t.__name__}") passed += 1 except AssertionError as e: print(f"FAIL {t.__name__}: {e}") except Exception as e: print(f"ERROR {t.__name__}: {type(e).__name__}: {e}") print(f"{passed}/{len(tests)} passed") return passed == len(tests) def test_memory_from_dict_stationary_pos_tuple(): """回归:memory.json 恢复的 stationary.pos 必须是 tuple—— 否则 _pick_raid_target 返回 list → _raid_point → pf.find key 含 list 崩溃(2026-08-07 agent2 线上每 tick TypeError)。""" from strategies.base import Memory m = Memory.from_dict({ "stationary": [["core_abc", {"pos": [1, 2], "count": 3, "first": 10, "last": 20}]]}) st = m.stationary["core_abc"] assert isinstance(st["pos"], tuple), st["pos"] assert st["pos"] == (1, 2) def test_pathfinder_accepts_list_pos(): """回归:pf.find 必须接受 list 坐标(调用方可能传 list)。""" from strategies.base import Pathfinder pf = Pathfinder(is_obstacle=lambda x, y: False, bounds=(-64, 63, -64, 63)) path = pf.find([0, 0], [3, 0]) assert path and tuple(path[0]) == (1, 0) def test_legacy_result_cannot_override_frozen_v014_genes(): import json import tempfile from deploy import load_genes payload = {"history": [{"gen": 3, "best": 99.0, "genes": { "max_population": 19, "selfdestruct_pop": 19, "worker_ratio": 0.6, "obsolete_gene": 123}}]} with tempfile.NamedTemporaryFile("w", suffix=".json") as fh: json.dump(payload, fh) fh.flush() genes = load_genes(fh.name) assert genes["max_population"] == 32 assert genes["selfdestruct_pop"] == 999 assert genes["worker_ratio"] == 0.6 assert "obsolete_gene" not in genes def test_status_exposes_manual_core_control(): a = mk_adapter() a.strategy.set_manual_core_migration("RIGHT") turn = make_turn() obs = a.build_observation(turn) status = a.build_status(turn.tick, obs, {"units": {}, "core": None}) assert status["core_control"] == { "movement_mode": "migration", "mode": "fixed", "direction": "RIGHT", "heading": "RIGHT", "step_direction": None, "phase": "stationed", "active": False, "paused": False, "wait_reason": None, } def test_status_stationed_mode_blocks_migration(): a = mk_adapter() a.strategy.station_core() a.strategy.set_manual_core_migration("RIGHT") turn = make_turn() obs = a.build_observation(turn) status = a.build_status(turn.tick, obs, {"units": {}, "core": None}) assert status["core_control"]["movement_mode"] == "stationed" assert status["core_control"]["phase"] == "stationed" assert status["core_control"]["active"] is False assert status["core_control"]["paused"] is False def test_core_move_start_failed_uses_submitted_direction(): a = mk_adapter() a._core_dir = "UP" a._sent_core_dir = "RIGHT" ev = ResolutionEvent( event_id=UUID(int=8), tick=9, event_type="CORE_MOVE_START_FAILED", reason_code="CORE_DESTINATION_TERRAIN_BLOCKED", actor_id=C_ID, target_id=None, position=(4, 5), values=None) out = a.translate_events(make_turn(events=[ev])) assert out[0]["type"] == "MOVE_BLOCKED" assert out[0]["direction"] == "RIGHT" assert out[0]["blocked_cell"] == (5, 5) assert out[0]["source_type"] == "CORE_MOVE_START_FAILED" if __name__ == "__main__": sys.exit(0 if _run_all() else 1)