File size: 11,984 Bytes
2149b73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b09aea
 
 
2149b73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b09aea
 
 
2149b73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62068da
 
 
 
 
 
 
 
 
83b8781
5695506
aa17a67
 
 
62068da
 
 
83b8781
 
 
 
 
 
 
 
 
 
 
 
aa17a67
83b8781
 
1b09aea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2149b73
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
"""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)