| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- """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
|