"""AutoFightBehavior 客户端对齐重构验证(真客户端 Lua 反推的行为规格)。 规格来源(2026-09-30 客户端逆向定案): - AutoFindMonsterCommand.lua: 选怪=全视野 squLen 最近, 跳同格怪, IsHaveMaster(宠物)永不打 - RequestLaunchCommand.lua: 普攻 minDis=1 — dist=0 客户端本地拒绝发包; 不在出刀位只发移动不发攻击 - AutoProxy.lua SetAFKTargetDeath: 击杀后原地等 0.2s 再重扫 - AssistProxy.lua checkAimlessMove: 无怪漫游=±50 随机中距点(|dx|+|dy|>20), 不是 BFS 最远点 """ import json import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) import pytest from run_juece13_wugong_autofight import ( ATTACK_INTERVAL, ATTACK_RADIUS, KILL_WAIT_S, PAPET_MIN_ID, AutoFightBehavior, ) from pbot.world.actor import ActorKey, Monster from pbot.world.mirror import WorldMirror from tests.test_wander import SpyConn MINE = "比奇矿区" def mk_mon(key: int, x: int, y: int, *, name: str = "矿区僵尸", dead: bool = False) -> Monster: return Monster(key=ActorKey(key), x=x, y=y, race=1049, name=name, is_dead=dead) def mine_world(x: int = 100, y: int = 100) -> WorldMirror: w = WorldMirror() w.me.map_name = MINE w.me.map_id = "d401" w.me.actor_key = 7 w.me.uid = "14570166001790661813000091989" w.me.x, w.me.y = x, y w.me.hp, w.me.max_hp = 500, 575 return w def fresh_af(spy: SpyConn, world: WorldMirror, **kw: object) -> AutoFightBehavior: """空闲态 AutoFightBehavior: 步进门全开, 事件清空.""" walls = kw.pop("walls", {(0, 0), (0, 1)}) # type: ignore[arg-type] af = AutoFightBehavior(sid=99, map_name=MINE, walls=walls, **kw) # pyright: ignore[reportArgumentType] af.on_enter(spy, world) af._last_step_t = 0.0 af._last_attack_t = 0.0 world.events.clear() spy.sent.clear() spy.pres.clear() return af def tick_until(af: AutoFightBehavior, spy: SpyConn, world: WorldMirror, n: int = 1) -> None: for _ in range(n): af.tick(spy, world) # pyright: ignore[reportArgumentType] world.events.clear() # ---------- 选怪: 真客户端过滤链 ---------- def test_pick_ignores_pet_ids() -> None: """7 位数短ID=宠物/玩家量级(客户端 IsHaveMaster 等价) — 永不选.""" w = mine_world() w.monsters[PAPET_MIN_ID + 12345] = mk_mon(PAPET_MIN_ID + 12345, 101, 100) w.monsters[56751] = mk_mon(56751, 105, 100) af = fresh_af(SpyConn(), w) m = af._pick_monster(w) assert m is not None and int(m.key) == 56751 def test_pick_nearest_in_full_view() -> None: """全视野(服务器推送范围)取最近, 不受旧 VISION=8 环扫限制.""" w = mine_world() w.monsters[111] = mk_mon(111, 110, 100) # dist 10 — 旧版扫不到 w.monsters[222] = mk_mon(222, 118, 100) # dist 18 af = fresh_af(SpyConn(), w) m = af._pick_monster(w) assert m is not None and int(m.key) == 111 def test_pick_skips_same_cell_monster() -> None: """同格(dist=0)怪客户端直接跳过(FindNearestMonster 明确排除).""" w = mine_world() w.monsters[111] = mk_mon(111, 100, 100) # 与我同格 w.monsters[222] = mk_mon(222, 103, 100) af = fresh_af(SpyConn(), w) m = af._pick_monster(w) assert m is not None and int(m.key) == 222 def test_pick_skips_dead_and_blacklisted() -> None: w = mine_world() w.monsters[111] = mk_mon(111, 101, 100, dead=True) w.monsters[222] = mk_mon(222, 102, 100) af = fresh_af(SpyConn(), w) af._big_monsters.add(222) assert af._pick_monster(w) is None # ---------- 逼近: 先走位后出刀, 永不进怪格 ---------- def test_approach_last_step_is_walk_not_run() -> None: """dist=2 时发 3011(1格走) — 真客户端最后一步永远 1 格, 防冲进怪格.""" spy = SpyConn() w = mine_world(100, 100) w.monsters[56751] = mk_mon(56751, 102, 100) # dist=2 af = fresh_af(spy, w) tick_until(af, spy, w, n=3) walks = [f for f in spy.sent if f[0] == 3011] runs = [f for f in spy.sent if f[0] == 3013] assert walks, "dist=2 必须发 3011 走步" assert not runs, "dist=2 不允许 3013 跑步(会冲到 dist=0)" def test_approach_never_steps_into_monster_cell() -> None: """逼近终点=怪相邻格: 任何情况下发出的移动落点都不等于怪坐标.""" spy = SpyConn() w = mine_world(100, 100) w.monsters[56751] = mk_mon(56751, 104, 100) # dist=4 af = fresh_af(spy, w) t = 1000.0 for _ in range(8): af._now_override = t af.tick(spy, w) # pyright: ignore[reportArgumentType] pre = spy.pres[-1] if spy.pres else 0 w.me.x, w.me.y = pre & 0xFFFF, (pre >> 16) & 0xFFFF af._pending = False af._last_step_t = 0.0 w.events.clear() t += 1.0 if max(abs(w.monsters[56751].x - w.me.x), abs(w.monsters[56751].y - w.me.y)) <= 1: break assert (w.me.x, w.me.y) != (104, 100), "绝不允许踩进怪格(dist=0 不结算)" assert max(abs(104 - w.me.x), abs(100 - w.me.y)) <= 1 def test_attack_only_in_range_and_interval() -> None: """dist=1(出刀位)才发 3014; dist=0 不发(客户端本地拒绝).""" spy = SpyConn() w = mine_world(100, 100) w.monsters[56751] = mk_mon(56751, 101, 100) # dist=1 af = fresh_af(spy, w) tick_until(af, spy, w, n=2) attacks = [f for f in spy.sent if f[0] == 3014] assert attacks, "dist=1 应立即出刀" # dist=0: 同格怪根本不该被选为目标 w2 = mine_world(101, 100) w2.monsters[56751] = mk_mon(56751, 101, 100) spy2 = SpyConn() af2 = fresh_af(spy2, w2) tick_until(af2, spy2, w2, n=2) assert af2._target_key != 56751 assert not [f for f in spy2.sent if f[0] == 3014], \ "同格怪不许打(打不中且浪费节拍)" def test_attack_interval_enforced() -> None: """出刀间隔内不重复发 3014(客户端 CD≈0.9s, 我们沿用 ATTACK_INTERVAL).""" spy = SpyConn() w = mine_world(100, 100) w.monsters[56751] = mk_mon(56751, 101, 100) af = fresh_af(spy, w) af.tick(spy, w) # pyright: ignore[reportArgumentType] n1 = len([f for f in spy.sent if f[0] == 3014]) af._now_override = time.time() + 0.05 # 间隔内 af.tick(spy, w) # pyright: ignore[reportArgumentType] n2 = len([f for f in spy.sent if f[0] == 3014]) assert n1 == 1 and n2 == 1, "间隔内不应再出刀" assert KILL_WAIT_S == 0.2 # AutoProxy 定案: 击杀后等 0.2s # ---------- 击杀后: 0.2s 原地等待 ---------- def test_kill_then_wait_window() -> None: """目标死亡计杀后, KILL_WAIT_S 窗口内不选新怪不漫游.""" spy = SpyConn() w = mine_world(100, 100) w.monsters[56751] = mk_mon(56751, 101, 100) w.monsters[999] = mk_mon(999, 105, 100) af = fresh_af(spy, w, kill_goal=5) tick_until(af, spy, w, n=2) assert af._target_key == 56751 # SC31 hp=0 → is_dead w.monsters[56751] = mk_mon(56751, 101, 100, dead=True) af._now_override = time.time() + 1.0 af.tick(spy, w) # pyright: ignore[reportArgumentType] w.events.clear() assert af.kills == 1 assert af._kill_wait_until > 0, "击杀后应进入等待窗口" af._now_override = time.time() + 1.05 # 仍在等待窗口内 spy.sent.clear() af.tick(spy, w) # pyright: ignore[reportArgumentType] assert af._target_key is None, "等待窗口内不应锁新目标" assert not [f for f in spy.sent if f[0] == 3014] af._now_override = af._kill_wait_until + 0.01 # 窗口已过 af.tick(spy, w) # pyright: ignore[reportArgumentType] assert af._target_key == 999 # ---------- 漫游: 随机中距点(真客户端 checkAimlessMove) ---------- def test_roam_random_midrange_not_farthest() -> None: """无怪漫游: ±50 内随机中距点(|dx|+|dy|>20), 不再 BFS 最远点.""" spy = SpyConn() w = mine_world(100, 100) af = fresh_af(spy, w, farthest_fn=None) af.tick(spy, w) # pyright: ignore[reportArgumentType] assert af._roam_dest is not None dx = abs(af._roam_dest[0] - 100) dy = abs(af._roam_dest[1] - 100) assert dx <= 50 and dy <= 50, "漫游点必须在 ±50 内" assert dx + dy > 20, "漫游点必须是中距点(太近=原地抖动)" def test_roam_dest_never_negative() -> None: """run9 实证: 出生在 (28,28) 时随机点出负坐标(-8,58)/(48,-12) — A* 必失败 → 0.6s 连刷 5 个漫游点空转. 漫游点必须钳制在地图内.""" for _ in range(200): spy = SpyConn() w = mine_world(5, 5) # 地图角落地带 af = fresh_af(spy, w, farthest_fn=None) af.tick(spy, w) # pyright: ignore[reportArgumentType] assert af._roam_dest is not None x, y = af._roam_dest assert x >= 0 and y >= 0, f"负坐标漫游点 ({x},{y})" def test_vision_no_longer_caps_targeting() -> None: """锁定 18 格外的怪不被弃目标(旧 VISION+2=10 会弃).""" spy = SpyConn() w = mine_world(100, 100) w.monsters[111] = mk_mon(111, 118, 100) af = fresh_af(spy, w) tick_until(af, spy, w, n=1) assert af._target_key == 111 af._now_override = time.time() + 5.0 af.tick(spy, w) # pyright: ignore[reportArgumentType] assert af._target_key == 111, "全视野目标不应因距离被弃" # ---------- SC10 宠物过滤(update.py) ---------- def test_sc10_rejects_pet_ids() -> None: """SC10: 人形(race 0/1/150)入 humanoids 登记不入怪表; 7位数宠物量级 非人形 race 的仍拒(cap_client_mine2: 5-6位数才是怪).""" import json as _json from pbot.codec.bitbuf import encode6 from pbot.codec.frame import Frame from pbot.world.update import WorldUpdater def sc10(info0: str, race: int) -> Frame: body = encode6(_json.dumps( {"info": [info0, "x", 1, 0, 1], "X": 100, "Y": 100, "race": race}).encode()) return Frame(msg_id=10, recog=race, p1=(100 << 16) | 100, p2=0, p3=0, tail=0, pre=0, body=body) w = WorldMirror() w.me.map_name = MINE up = WorldUpdater() up.apply(w, sc10("288999", 150)) # 人形(英雄/玩家) assert 288999 not in w.monsters assert 288999 in w.humanoids up.apply(w, sc10("56751", 1049)) # 5位数怪 assert 56751 in w.monsters def test_attack_radius_is_one() -> None: """近战结算距离=1(普攻 minDis=maxDis=1, 服务器权威).""" assert ATTACK_RADIUS == 1 def test_skill_rotation_on_consecutive_hits() -> None: """真客户端攻击流(cap_client_mine2): skillID 在 {0,7,12,25,26} 轮换 (普攻/刺杀/攻杀/半月/烈火) — 连续出刀轮换技能提高结算伤害.""" spy = SpyConn() w = mine_world(100, 100) w.monsters[56751] = mk_mon(56751, 101, 100) af = fresh_af(spy, w) t = 1000.0 seen = [] for i in range(8): af._now_override = t af.tick(spy, w) # pyright: ignore[reportArgumentType] for f in spy.sent: if f[0] == 3014: seen.append(json.loads(f[5])["skillID"]) spy.sent.clear() t += ATTACK_INTERVAL + 0.01 assert set(seen) >= {0, 12, 25, 26}, f"技能应轮换, 实际: {seen}" @pytest.mark.parametrize("bad", [PAPET_MIN_ID, PAPET_MIN_ID * 9]) def test_pet_min_id_threshold(bad: int) -> None: """PAPET_MIN_ID 起为宠物/玩家量级.""" assert bad >= PAPET_MIN_ID # ---------- run14 事故固化: 低血逃跑死循环 / hp=0 待机 ---------- def test_flee_target_blacklisted_not_recomputed_same_point() -> None: """run14: 逃跑点(向量*30)拉黑后, 下一 tick 必须算出新逃跑点 (垂直扰动), 而非同向量重算同点 → 60ms 死循环 24s 挨打致死.""" w = mine_world(x=216, y=37) w.me.hp, w.me.max_hp = 300, 628 w.monsters[98172] = mk_mon(98172, 217, 38, name="黑色恶蛆") spy = SpyConn() af = fresh_af(spy, w) af._now_override = 1000.0 # noqa: SLF001 af.tick(spy, w) # noqa: SLF001 first = af._roam_dest # noqa: SLF001 assert first is not None, "低血必须立即产生逃跑点" # 模拟该点 A* 失败被拉黑 af._roam_blacklist[first] = 1e9 # noqa: SLF001 af.tick(spy, w) # noqa: SLF001 second = af._roam_dest # noqa: SLF001 assert second is not None and second not in af._roam_blacklist # noqa: SLF001 assert second != first, "拉黑后不得重算同一个逃跑点" def test_fleeing_never_selects_target() -> None: """run14: 逃跑中选怪→弃目标 60ms 循环 — 逃跑态禁止选怪.""" w = mine_world(x=216, y=37) w.me.hp, w.me.max_hp = 300, 628 # < FLEE_RATIO=0.55 w.monsters[98172] = mk_mon(98172, 217, 38) spy = SpyConn() af = fresh_af(spy, w) af._now_override = 1000.0 # noqa: SLF001 for _ in range(5): af.tick(spy, w) # noqa: SLF001 assert af._target_key is None, "低血逃跑中绝不允许锁定目标" # noqa: SLF001 def test_dead_wait_blocks_all_actions() -> None: """run14: hp=0 死亡后仍出刀 4 次 — me_die 事件进入待机态, 屏蔽选怪/攻击/漫游直到换图 DONE.""" from pbot.world.mirror import ActorEvent w = mine_world(x=216, y=37) w.me.hp, w.me.max_hp = 0, 628 w.monsters[98172] = mk_mon(98172, 217, 38) spy = SpyConn() af = fresh_af(spy, w) af._now_override = 1000.0 # noqa: SLF001 w.events.append(ActorEvent(kind="me_die", actor=None, detail="by=98217")) af.tick(spy, w) # noqa: SLF001 assert af._dead_wait is True # noqa: SLF001 w.events.clear() spy.sent.clear() for _ in range(3): af.tick(spy, w) # noqa: SLF001 assert not spy.sent, "死亡待机态不得发送任何帧(攻击/移动均禁止)" # noqa: SLF001