chinatown_1 / tests /test_deploy_adapter.py
snapsheep
fix(strategy): pause migration for unit actions
aa17a67
Raw
History Blame Contribute Delete
12 kB
"""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)