Procházet zdrojové kódy

feat(combat): 寻图杀怪统一切换 AutoFight 成熟模式 + 抢怪避让

- t02/t03 重写: KillBehavior 仅作传送链, 战斗=AutoFightBehavior(贴身出刀
  ATTACK_RADIUS=1/4刀不中重锁/低血逃跑 — 9-30 三图百杀验证)
- Monster.contested_t: SC31 attacker≠我 打标 8s(别人在打的怪不选不追,
  交战中被抢立即弃目标); 我打的刀清标
- SC10/SC11/SC14/SC20 重建点保留 contested_t
- 根因备注: 此前两小时'幽灵缓存'排查实为 contended/contested 拼写撞车
wuzilong před 1 měsícem
rodič
revize
9d6cd7ac12

+ 50 - 18
pipeline_bot/pipeline/tasks/t02_biqi_kill20.py

@@ -1,29 +1,61 @@
-"""T2 — 新手接待→比奇大城寻图杀怪 20(怪不够点【点我刷怪】NPC)。"""
+"""T2 — 新手接待→比奇大城寻图杀怪 20(昨天验证的 AutoFight 模式)。
+
+传送链: KillBehavior renwu_mov,1(任务指引, 等待图/异图→比奇);
+战斗: AutoFightBehavior(客户端 RequestLaunchCommand 复刻 — 贴身出刀
+ATTACK_RADIUS=1, 4刀不中重锁, 10刀拉黑, 低血逃跑)。
+比奇是开放主城(非副本), 死亡回城后仍在比奇 → 轮次循环续杀。
+"""
 from __future__ import annotations
 
+import time
+
 from pipeline.ctx import PipelineContext, TaskResult
 
 TASK_TITLE = "T2 比奇大城杀怪20"
 
-BIQI_MAP = "比奇大城"
 KILL_TARGET = 20
+BIQI_MAP = "比奇大城"
+_MAX_ROUNDS = 6
 
 
 def run_task(ctx: PipelineContext) -> TaskResult:
-    """任务链传送比奇 → 杀满增量 20 只(全局计数取增量)。"""
+    """进比奇(renwu 链) → AutoFight 轮次杀满 20 只增量。"""
+    import sys
+    from pathlib import Path
+
+    scripts = str(Path(__file__).resolve().parents[2] / "scripts")
+    if scripts not in sys.path:
+        sys.path.insert(0, scripts)
+    from run_juece13_wugong_autofight import AutoFightBehavior
+
     from pbot.bot.kill import KillBehavior
-    from pbot.bot.runner import run
-
-    baseline = ctx.world.kills
-    goal = baseline + KILL_TARGET
-    bh = KillBehavior(sid=ctx.sid, kill_target=goal, renwu_act="renwu_mov,1",
-                      allow_reroll=True)
-    result = run(ctx.conn, ctx.world, ctx.updater, bh,
-                 duration_s=max(900.0, KILL_TARGET * 60.0))
-    killed = ctx.world.kills - baseline
-    ok = killed >= KILL_TARGET
-    ctx.log.info("[T2] result=%s 本段击杀=%d/%d map=%s",
-                 result.value, killed, KILL_TARGET, ctx.world.me.map_name)
-    if ok:
-        return TaskResult.done(f"击杀 {killed}/{KILL_TARGET} @比奇大城")
-    return TaskResult.fail(f"仅 {killed}/{KILL_TARGET}(result={result.value})")
+    from pbot.bot.pathfind import load_walls_from_map
+    from pbot.bot.runner import pump, run
+
+    root = Path(__file__).resolve().parents[2]
+    walls = load_walls_from_map(str(root / "assets/maps/0.map"))
+    total = 0
+    for rnd in range(1, _MAX_ROUNDS + 1):
+        if ctx.world.me.map_name != BIQI_MAP:
+            ctx.log.info("[T2] 第%d轮 进图(当前 %s)", rnd,
+                         ctx.world.me.map_name)
+            bh = KillBehavior(sid=ctx.sid, kill_target=0,
+                              renwu_act="renwu_mov,1", allow_reroll=True)
+            r = run(ctx.conn, ctx.world, ctx.updater, bh, duration_s=300.0)
+            if ctx.world.me.map_name != BIQI_MAP:
+                ctx.log.warning("[T2] 传送失败 result=%s — 3s 重试", r.value)
+                time.sleep(3.0)
+                continue
+            time.sleep(3.0)
+            _ = pump(ctx.conn, ctx.world, ctx.updater, 2.0)
+        need = KILL_TARGET - total
+        af = AutoFightBehavior(sid=ctx.sid, map_name=BIQI_MAP, walls=walls,
+                               farthest_fn=None, kill_goal=need)
+        r = run(ctx.conn, ctx.world, ctx.updater, af,
+                duration_s=max(600.0, need * 60.0))
+        total += af.kills
+        ctx.log.info("[T2] 第%d轮 result=%s 轮杀=%d 累计=%d/%d", rnd,
+                     r.value, af.kills, total, KILL_TARGET)
+        if total >= KILL_TARGET:
+            return TaskResult.done(f"比奇击杀 {total}/{KILL_TARGET}")
+    return TaskResult.fail(f"{total}/{KILL_TARGET} 轮次耗尽")

+ 48 - 23
pipeline_bot/pipeline/tasks/t03_cave_kill20.py

@@ -1,8 +1,12 @@
-"""T3 — 任务指引→天然洞穴寻图杀怪 20(驻留洞穴, 不点任务跳转)。"""
+"""T3 — 任务指引→天然洞穴寻图杀怪 20(昨天验证的 AutoFight 模式)。
+
+传送链: KillBehavior renwu_mov,2(任务指引 → 天然洞穴一层, 全随机落点);
+战斗: AutoFightBehavior(d011 墙格先验 + 贴身出刀 + 低血逃跑)。
+洞穴是副本图: 死亡回盟重 → 轮次循环走任务链/传送回洞续杀。
+"""
 from __future__ import annotations
 
-import sys
-from pathlib import Path
+import time
 
 from pipeline.ctx import PipelineContext, TaskResult
 
@@ -10,27 +14,48 @@ TASK_TITLE = "T3 天然洞穴杀怪20"
 
 KILL_TARGET = 20
 CAVE_MAP = "天然洞穴一层"
-_SCRIPTS = str(Path(__file__).resolve().parents[2] / "scripts")
+_MAX_ROUNDS = 8
 
 
 def run_task(ctx: PipelineContext) -> TaskResult:
-    """进洞驻留杀怪; 达标且终图在洞穴才算成功。"""
-    if _SCRIPTS not in sys.path:
-        sys.path.insert(0, _SCRIPTS)
-    from run_juece13_cave30_v2 import CaveStayKillBehavior
-
-    from pbot.bot.runner import run
-
-    baseline = ctx.world.kills
-    goal = baseline + KILL_TARGET
-    bh = CaveStayKillBehavior(sid=ctx.sid, kill_target=goal,
+    """进洞(renwu 链) → AutoFight 轮次杀满 20 只增量。"""
+    import sys
+    from pathlib import Path
+
+    scripts = str(Path(__file__).resolve().parents[2] / "scripts")
+    if scripts not in sys.path:
+        sys.path.insert(0, scripts)
+    from run_juece13_wugong_autofight import AutoFightBehavior
+
+    from pbot.bot.kill import KillBehavior
+    from pbot.bot.pathfind import load_walls_from_map
+    from pbot.bot.runner import pump, run
+
+    root = Path(__file__).resolve().parents[2]
+    walls = load_walls_from_map(str(root / "assets/maps/d011.map"))
+    total = 0
+    for rnd in range(1, _MAX_ROUNDS + 1):
+        if ctx.world.me.map_name != CAVE_MAP:
+            ctx.log.info("[T3] 第%d轮 进洞(当前 %s)", rnd,
+                         ctx.world.me.map_name)
+            bh = KillBehavior(sid=ctx.sid, kill_target=0,
                               renwu_act="renwu_mov,2", allow_reroll=True)
-    result = run(ctx.conn, ctx.world, ctx.updater, bh,
-                 duration_s=max(1500.0, KILL_TARGET * 75.0))
-    killed = ctx.world.kills - baseline
-    ok = killed >= KILL_TARGET and ctx.world.me.map_name == CAVE_MAP
-    ctx.log.info("[T3] result=%s 击杀=%d/%d map=%s", result.value, killed,
-                 KILL_TARGET, ctx.world.me.map_name)
-    if ok:
-        return TaskResult.done(f"洞穴击杀 {killed}/{KILL_TARGET}")
-    return TaskResult.fail(f"{killed}/{KILL_TARGET} map={ctx.world.me.map_name}")
+            r = run(ctx.conn, ctx.world, ctx.updater, bh, duration_s=300.0)
+            if ctx.world.me.map_name != CAVE_MAP:
+                ctx.log.warning("[T3] 传送失败 result=%s — 3s 重试", r.value)
+                time.sleep(3.0)
+                continue
+            time.sleep(3.0)
+            _ = pump(ctx.conn, ctx.world, ctx.updater, 2.0)
+        need = KILL_TARGET - total
+        af = AutoFightBehavior(sid=ctx.sid, map_name=CAVE_MAP, walls=walls,
+                               farthest_fn=None, kill_goal=need)
+        r = run(ctx.conn, ctx.world, ctx.updater, af,
+                duration_s=max(600.0, need * 60.0))
+        total += af.kills
+        ctx.log.info("[T3] 第%d轮 result=%s 轮杀=%d 累计=%d/%d", rnd,
+                     r.value, af.kills, total, KILL_TARGET)
+        if total >= KILL_TARGET:
+            return TaskResult.done(f"洞穴击杀 {total}/{KILL_TARGET}")
+    return TaskResult.fail(f"{total}/{KILL_TARGET} 轮次耗尽 "
+                           f"map={ctx.world.me.map_name}")

+ 16 - 1
pipeline_bot/scripts/run_juece13_wugong_autofight.py

@@ -69,6 +69,10 @@ DANGER_DENSITY = False
 FLEE_RATIO = 0.55
 BIG_HIT = 25
 STEP_INTERVAL = 0.6
+# 抢怪避让: SC31 attacker≠我 的怪在该窗口内不选不追(2026-10-01 需求 —
+# 全区广播 SC31 可见他人战斗; 窗口=他人两刀间隔的量级, 过长会把残血怪
+# 也让出去, 过短形同虚设)
+CONTENDED_AVOID_S = 8.0
 
 
 import re
@@ -135,11 +139,12 @@ class AutoFightBehavior(Behavior):
     # ---- 怪物选择: 客户端 AutoFindMonsterCommand 定案 ----
     # 全视野(=服务器 SC10/30 推送范围)取切比雪夫最近; 过滤链:
     #   死亡/黑名单/宠物量级ID(IsHaveMaster 等价)/与我同格(dist=0 打不中
-    #   且客户端 FindNearestMonster 明确跳过)
+    #   且客户端 FindNearestMonster 明确跳过)/他人交战中(抢怪避让)
     def _pick_monster(self, world: WorldMirror):
         me = world.me
         best = None
         best_d = 1 << 30
+        now = self._now()
         for m in world.monsters.values():
             if m.is_dead or int(m.key) in self._big_monsters:
                 continue
@@ -149,6 +154,8 @@ class AutoFightBehavior(Behavior):
                 continue
             if (m.x, m.y) == (0, 0):
                 continue  # attacker 揭示怪: 坐标未补全前不可作为目标
+            if m.contested_t and now - m.contested_t < CONTENDED_AVOID_S:
+                continue  # 他人正在打(SC31 attacker≠我) — 避让
             d = max(abs(m.x - me.x), abs(m.y - me.y))
             if d < best_d:
                 best, best_d = m, d
@@ -322,6 +329,14 @@ class AutoFightBehavior(Behavior):
                 elif e.actor == self._target_key:
                     # 目标怪掉血 = 攻击有效, 重置零结算计数(厚血怪一直打)
                     self._hit_counter[e.actor] = 0
+                    # 他人也在这只怪身上出刀(SC31 attacker≠我) — 判定被抢:
+                    # 弃目标避让, 不再把刀浪费在竞争目标上
+                    tm = world.monsters.get(e.actor)
+                    if tm is not None and tm.contested_t and \
+                            now - tm.contested_t < CONTENDED_AVOID_S:
+                        _LOG.info("[被抢] #%d 他人交战中 — 弃之避让", e.actor)
+                        self._target_key = None
+                        self._roam_dest = None
         world.events.clear()
 
         # 击杀等待窗口(AutoProxy SetAFKTargetDeath 0.2s): 等 loot/尸体,

+ 4 - 0
pipeline_bot/src/pbot/world/actor.py

@@ -24,6 +24,10 @@ class Monster:
     name: str
     is_dead: bool
     is_boss: bool = False
+    # 他人抢怪避让: SC31 attacker≠我 的最近时刻(时间戳); 选怪在
+    # 该时刻+CONTENDED_AVOID_S 内跳过此怪(2026-10-01 需求: 别人在打
+    # 的怪我们不打 — 全区广播 SC31 天然可见他人战斗)
+    contested_t: float = 0.0
 
 
 @dataclass(frozen=True, slots=True)

+ 18 - 6
pipeline_bot/src/pbot/world/update.py

@@ -340,7 +340,9 @@ class WorldUpdater:
         aid = _safe_int(body_text(f))
         m = world.monsters.get(aid)
         if m:
-            world.monsters[aid] = _mk_monster(aid, m.x, m.y, m.race, m.name, False)
+            world.monsters[aid] = _mk_monster(aid, m.x, m.y, m.race, m.name,
+                                              False,
+                                              contested_t=m.contested_t)
             world.record("mon_reborn", m.name, aid)
 
     def _on_out_of_sight(self, world: WorldMirror, f: Frame) -> None:
@@ -378,7 +380,8 @@ class WorldUpdater:
         known = world.monsters.get(aid)
         if known is not None:
             world.monsters[aid] = _mk_monster(aid, x, y, known.race,
-                                              known.name, known.is_dead)
+                                              known.name, known.is_dead,
+                                              contested_t=known.contested_t)
         elif x or y:
             world.monsters[aid] = _mk_monster(aid, x, y, 0, "radar", False)
             world.record("radar_mon", uid, aid)
@@ -401,7 +404,8 @@ class WorldUpdater:
         known = world.monsters.get(aid)
         if known is not None:
             world.monsters[aid] = _mk_monster(aid, x, y, known.race,
-                                              known.name, known.is_dead)
+                                              known.name, known.is_dead,
+                                              contested_t=known.contested_t)
             return
         if aid in world.npcs:
             return
@@ -536,9 +540,17 @@ class WorldUpdater:
                 world.record("attacker_revealed", str(atk), atk)
         if aid in world.monsters:
             m = world.monsters[aid]
+            # 抢怪避让: 他人打这刀 → 记 contested_t(选怪窗口内跳过);
+            # 我打的刀 → 清标(归属确立)
+            contested = m.contested_t
+            if strike.attacker_uid and strike.attacker_uid != world.me.uid:
+                contested = time.time()
+            elif strike.attacker_uid == world.me.uid:
+                contested = 0.0
             world.monsters[aid] = _mk_monster(
                 aid, m.x, m.y, m.race, m.name,
-                m.is_dead or strike.hp <= 0)
+                m.is_dead or strike.hp <= 0,
+                contested_t=contested)
         detail = (f"{aid} hp={strike.hp}/{strike.max_hp}"
                   f" dmg={strike.damage} by={strike.attacker_uid}")
         if strike.hp <= 0:
@@ -592,9 +604,9 @@ class WorldUpdater:
 
 
 def _mk_monster(key: int, x: int, y: int, race: int, name: str, is_dead: bool,
-                is_boss: bool = False) -> Monster:
+                is_boss: bool = False, contested_t: float = 0.0) -> Monster:
     return Monster(key=ActorKey(key), x=x, y=y, race=race, name=name,
-                   is_dead=is_dead, is_boss=is_boss)
+                   is_dead=is_dead, is_boss=is_boss, contested_t=contested_t)
 
 
 def _mk_npc(key: int, aid: str, name: str, x: int, y: int, index: int = 0) -> Npc:

+ 40 - 0
pipeline_bot/tests/test_pipeline_units.py

@@ -100,3 +100,43 @@ def test_zone_label() -> None:
 def test_checklist_fname() -> None:
     assert _checklist_fname("38区", "凌风") == "进度_玖扬复古176_38区_凌风.md"
     assert _checklist_fname('38/区:*?"x', "凌\\风") == "进度_玖扬复古176_38_区_x_凌_风.md"
+
+
+# ---- 抢怪避让: SC31 attacker≠我 → contested_t 打标, 我打 → 清标 ----
+import json as _json  # noqa: E402
+
+from pbot.codec.bitbuf import encode6 as _encode6  # noqa: E402
+from pbot.codec.frame import Frame as _Frame  # noqa: E402
+from pbot.world.actor import ActorKey as _ActorKey  # noqa: E402
+from pbot.world.actor import Monster as _Monster  # noqa: E402
+from pbot.world.update import WorldUpdater as _WU  # noqa: E402
+
+
+def _sc31(victim: str, attacker: str) -> _Frame:
+    body = _json.dumps({"Hp": 10, "MaxHp": 20, "Damage": 5,
+                        "UserID": victim, "AttackUserID": attacker}).encode()
+    return _Frame(msg_id=31, recog=0, p1=0, p2=0, p3=0, pre=0, tail=0,
+                  body=_encode6(body))
+
+
+def test_sc31_other_attacker_marks_contended() -> None:
+    w = _new_world_with_monster()
+    _WU().apply(w, _sc31("44444", "9999000179080001"))
+    assert w.monsters[44444].contested_t > 0.0
+
+
+def test_sc31_my_hit_clears_contended() -> None:
+    w = _new_world_with_monster()
+    w.monsters[44444] = _Monster(key=_ActorKey(44444), x=5, y=5, race=0,
+                                 name="鸡", is_dead=False, contested_t=123.0)
+    _WU().apply(w, _sc31("44444", w.me.uid))
+    assert w.monsters[44444].contested_t == 0.0
+
+
+def _new_world_with_monster():
+    from pbot.world.mirror import WorldMirror
+    w = WorldMirror()
+    w.me.uid = "14590001790847124000069434"
+    w.monsters[44444] = _Monster(key=_ActorKey(44444), x=5, y=5, race=0,
+                                 name="鸡", is_dead=False)
+    return w