quest_bot3.py 66 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334
  1. # -*- coding: utf-8 -*-
  2. """
  3. quest_bot3.py —— 推送驱动微缩客户端(真实客户端双向通信模式重构版)。
  4. 2026-09-22 cap2.jsonl 抓包破译的关键规则(本文件的设计依据):
  5. 1. 服务器是流程的主人:
  6. SC801 传送帧(recog=X,p1=Y,p2=dir,p3=256,body={"UserID":票据})
  7. → 客户端立即 CS5012+CS5027(body=新地图名) 加载资源 → 落地后 CS1 ACK
  8. SC51 地图初始化(p1=X,p2=Y) → 更新自身坐标/地图名
  9. SC10/50 视野 → NPC/怪物表(新手接待NPC的 UserID 每角色不同: 矿工10032/斗士10033, 必须动态发现)
  10. 2. CS1 落地确认: recog=新X, p1=新Y, p2=dir, p3=32171624, tail=943,
  11. body=encode6("+GOOD/<自增毫秒"), 计数器初值 52549000000 量级, 每步 +547。
  12. (每步都发也无害 — 真实客户端走路时每步 CS1)
  13. 3. 走路: CS3013(跑2格)/CS3011(走1格): p1=dir(0-7), p2=起点(Y<<16|X), body=地图名。
  14. 实测服务器按 dir 推算落点, body 地图名可以为过期缓存(斗士全程带"等待地图"走比奇网格)。
  15. 4. 任务规则(用户实测): 点击任务指引(@renwu_mov,1) → 服务器直接传送角色到出生地
  16. → 客户端自己跑到新手接待NPC → 点NPC(CS1010, index=34) → 传送新手村(比奇)
  17. → 跟随指引跑到任务点 → @startguaji 挂机 → SC32×N → @stopguaji。
  18. 5. 防呆: @renwu_mov 后必须在 CS5012/5027 里回发服务器指定的地图名, 否则流程卡死。
  19. 6. 移动全链路(2026-09-23 Lua真源定案, 解密源码/mod_fgcq/stab/scripts):
  20. BehaviorMove(行为树,每帧) → checkNextMovePos(A*路径下一格,可合1-3格)
  21. → player:SetMoveTo+SetAction(WALK/RUN) → 动画到点 SetConfirmed(false)
  22. → gamePlayerController:handleActionBegin 发帧:
  23. LuaSendMsg(3011/3013, point=Y<<16|X, 0, dir, lastPoint, mapID)
  24. → 服务器 SC11 自回显/SC1 确认 → SetConfirmed(true) → 下一格;
  25. 超时-5s强制确认。服务器开关 gameoptions.RunOne 开启时走一格也用 RUN(3013/3012)。
  26. 等待图滞留带(y=0出生行, 0139.map 中 y0-2 全墙)内一切移动帧被拒(SC28 recog=22/24),
  27. 唯一出口=新手接待NPC对话(CS1010 idx=35实证)传比奇;
  28. @renwu_mov 传回等待图落点(23,25)属可走区, 但移动仍需 SC20102(自动寻路目标)
  29. → CS5013(START,x,y) 注册寻路会话(AutoMoveBeginCommand), 未注册则 SC28 全拒。
  30. 架构: 单线程事件循环(recv 泵 + 定时器) + 显式状态机 — 与真实客户端
  31. "recv_loop 常驻线程 + Lua 消息订阅"同构, 但无线程竞争。
  32. 状态机: ENTER → [等待图: 找NPC→走近→CS1010] → TELEPORT → [比奇: 走向任务点] →
  33. GUAJI(挂机杀怪) → 杀满 N → STOP → 退出
  34. 用法:
  35. .venv/bin/python quest_bot3.py --zone 14492165 --name 斗士9906 [--kill 2] [--dry-run]
  36. 等待图走动: .venv/bin/python quest_bot3.py --mode wait [--zone 0=自动选最新区] [--minutes 2]
  37. """
  38. import argparse
  39. import json
  40. import os
  41. import random
  42. import re
  43. import socket
  44. import struct
  45. import sys
  46. import time
  47. import requests as rq
  48. import urllib3
  49. urllib3.disable_warnings()
  50. HERE = __file__.rsplit("/", 1)[0]
  51. sys.path.insert(0, HERE)
  52. from encode6bitbuf_final import encode6, decode6 # noqa: E402
  53. import login_996pc as L # noqa: E402
  54. import cq2_pure_enter as P # noqa: E402
  55. from combat_bot import (CombatBot, send_init_frames, DIRS, log, pack_point, # noqa: E402
  56. lo16, hi16, proto_map_name)
  57. from mir_map import load as map_load # noqa: E402
  58. STEP_MS = 547 # 实测步长
  59. # 杀怪区: cap3 抓包实证战斗发生在 (287-288, 621-623) — 传送落点(292,618)旁 4 格。
  60. # (288,622)在 0.map 文件中是墙格(服务器网格与文件有1-2格偏差) → 取邻格(287,622)。
  61. # 旧值(292,580)是斗士 run 终点, 实测无怪(run9 站桩7分钟)。
  62. KILL_AREA = (287, 622)
  63. WANDER_RANGE = 50 # 游走找怪半径(Lua checkAimlessMove 同款)
  64. QUEST_POINT = (292, 618) # 比奇任务点(传送落点)
  65. WAIT_MAP = "等待地图"
  66. # 协议地图名(SC51 MapName/MapId) → .map 文件。
  67. # 抓包实证: 进盟重土城加载 '3', 点NPC传送比奇加载 '0' → 比奇='0'(0.map 700x700),
  68. # 盟重='3'(3.map 1000x800)。(旧会话总结中两者写反, 已按坐标可走性+抓包双重纠正)
  69. MAP_FILES = {WAIT_MAP: "0139", "比奇大城": "0", "盟重土城": "3", "0": "0", "3": "3"}
  70. _MAP_OBJS = {}
  71. def _map_cached(fname):
  72. if fname not in _MAP_OBJS:
  73. try:
  74. _MAP_OBJS[fname] = map_load(fname)
  75. except Exception:
  76. _MAP_OBJS[fname] = None
  77. return _MAP_OBJS[fname]
  78. def map_file_for(mname, x, y):
  79. """按当前位置在哪张图可走来选 .map 文件(名字不可信: 比奇协议id='0'撞盟重文件名)。"""
  80. if mname in MAP_FILES:
  81. return MAP_FILES[mname]
  82. for cand in ("3", "0", "0139"):
  83. m = _map_cached(cand)
  84. if m and m.in_bounds(x, y) and m.walkable(x, y):
  85. return cand
  86. return None
  87. def _walkable_at(x, y):
  88. """按当前图文件判断可走(服务器网格与文件有1-2格偏差, 仅作游走候选粗筛)。"""
  89. for cand in ("0", "3", "0139"):
  90. m = _map_cached(cand)
  91. if m and m.in_bounds(x, y):
  92. return m.walkable(x, y)
  93. return False
  94. class PushBot(CombatBot):
  95. """推送驱动的微缩客户端。
  96. 与旧 WalkBot 的区别:
  97. - SC801(传送) → 自动加载地图资源(CS5012/5027) + CS1 落地 ACK(对齐真实客户端)
  98. - SC51 → 强制校正坐标+地图名(过期的客户端地图缓存不再污染走路 body)
  99. - NPC/UserID 动态发现(SC10), 不硬编码
  100. - 单循环状态机, 所有推进都由 pump() 里收到的推送触发
  101. """
  102. def __init__(self, sock, sid, name):
  103. super().__init__(sock, sid, name)
  104. self.good_ms = 52549000000 + random.randint(0, 10000) # CS1 计数器(实测区间)
  105. self.state = "ENTER" # ENTER/WALK_NPC/TELEPORT/WALK_QUEST/GUAJI/DONE
  106. self.pending_map_ack = None # SC801 到达时立即回发的目标图名(CS5012/5027 body)
  107. self.teleports = 0
  108. self.npc_10032 = None # 新手接待 NPC(actordata)
  109. self.hp = self.max_hp = 0 # SC53/SC31 状态跟踪(引擎必处理帧)
  110. self.missions = {} # SC55 任务状态 {type: flag} flag:0未接/1进行/2可交
  111. self.mission_bodies = {} # SC55 原样任务体 {type: json} — CS3031 回构造用
  112. self._last_step_t = 0.0
  113. self._step_pending_ack = None # (dx, dy, due_t)
  114. self._landing_ack_due = None # (x, y, dir, due_t) 传送落地ACK
  115. self._conf_pending = False # SC1 行为确认门控(mIsConfirmed 同款)
  116. self.mon_kills = 0 # SC32 计数(与父类 kills 分开, 供挂机停止判定)
  117. self.dead = False # SC32 body=自己uid → 死亡态(等SC27复活)
  118. self.on_event = None # debug hook
  119. # ---------- 覆写: 推送处理 ----------
  120. def _dispatch(self, mid, recog, p1, p2, p3, body):
  121. ev = []
  122. if mid == 1:
  123. # SC1 = MSG_SC_ACTION_CONFIREM_SUCCESS(主玩家行为确认, netMessageDef:94)
  124. # Lua: handle_MSG_SC_ACTION_CONFIRM → SetConfirmed(true) — 走路/攻击
  125. # 的每一步都要等它, 没确认前真实客户端不发下一个动作。
  126. if getattr(self, "_conf_pending", False):
  127. self._conf_pending = False
  128. self.confirmed_at = (self.me["x"], self.me["y"], time.time())
  129. ev.append(("action_confirmed",))
  130. if mid == 10:
  131. # SC10 进视野: (race, dir) 统计 + 保留 body 样本供离线分析。
  132. # 实测新手怪 race=1049(39只集中出现), type=lo16(p3) 实为 255/256(dir 域)。
  133. self.sc10_types = getattr(self, "sc10_types", {})
  134. t = lo16(recog)
  135. self.sc10_types[t] = self.sc10_types.get(t, 0) + 1
  136. if lo16(recog) not in (0, 1, 5, 11) and len(getattr(self, "sc10_bodies", [])) < 10:
  137. j2 = self._jbody(body)
  138. if j2:
  139. self.sc10_bodies = getattr(self, "sc10_bodies", []) + [(recog, p1, p2, p3, j2)]
  140. if mid == 800:
  141. # 传送帧真身 = SC800(cap3 实证): body=JSON{"UserID":"<自己长uid>"}, p3=256,
  142. # 坐标在随后的 SC51 里。SC801 是"别人飞进视野"广播, 勿混。
  143. j = self._jbody(body) or {}
  144. uid = str(j.get("UserID", ""))
  145. myuid = self.me.get("uid") or ""
  146. if uid and uid == myuid:
  147. self.teleports += 1
  148. dest = self.pending_map_ack or self._guess_map_name(self.me["x"], self.me["y"])
  149. self._landing_ack_due = (self.me["x"], self.me["y"], self.me["dir"],
  150. time.time() + 0.5)
  151. ev.append(("teleport", dest))
  152. if mid == 801:
  153. # 传送帧(引擎:MSG_SC_NET_PLAYER_FLY_IN)。注意: 自己出生也会来一帧 FLY_IN,
  154. # 只有位移>5格才算真传送。抓包时序: SC801→30ms CS5012/5027(body=目标图名)。
  155. x, y, d = recog, p1, p2 & 0xFF
  156. if y & 0x800:
  157. y &= 0x7FF # 出生帧 Y bit11 编码位
  158. moved = max(abs(x - self.me["x"]), abs(y - self.me["y"]))
  159. self.me["x"], self.me["y"], self.me["dir"] = x, y, d
  160. if moved > 5:
  161. self.teleports += 1
  162. dest = self.pending_map_ack or self._guess_map_name(x, y)
  163. self._send_map_load(dest)
  164. self._landing_ack_due = (x, y, d, time.time() + 0.5)
  165. ev.append(("teleport", x, y, dest))
  166. else:
  167. ev.append(("fly_in_self", x, y))
  168. elif mid == 51:
  169. # SC51 场景创建 — body=JSON{MapName(显示名), MapId(协议ID)}
  170. # 布局(cap_unlock t=45.00 批量子帧实证): recog=X, p1=Y(2048=bit11标志,
  171. # 非坐标) — 与 SC634/SC11/SC28/SC1 同族。旧按 p1=X,p2=Y 解析 →
  172. # 出生坐标整体读错(如(23,25)读成(25,0)) → 从错位起点发移动帧 →
  173. # 服务器 SC28 拉回真位置 → 表现为"角色永远走不动"(全案根因)。
  174. j = self._jbody(body) or {}
  175. newname = j.get("MapName") or str(j.get("MapId", "?"))
  176. old = self.me.get("mapname")
  177. self.me["mapname"] = newname
  178. if j.get("MapId") is not None:
  179. self.me["map"] = str(j["MapId"])
  180. nx = recog & 0xFFFF if recog > 0 else self.me["x"]
  181. ny = p1 & 0xFFFF if p1 > 0 else self.me["y"]
  182. self.me["x"], self.me["y"] = nx, ny
  183. # 坐标跳变即传送/出生: lastPoint 重置(CS3011 p3 连续性校验)
  184. self.me["last_x"], self.me["last_y"] = nx, ny
  185. if newname != old:
  186. self.monsters.clear()
  187. self.npcs.clear()
  188. # 换图即主动回 CS5012/5027(协议图名) — cap3 实证客户端必发,
  189. # 不发则服务器不下发该图视野推送(SC10 一帧不来, diag 实证)
  190. self._send_map_load(newname)
  191. ev.append(("map", newname, nx, ny))
  192. elif mid == 634:
  193. # SC634 场景切换(跨图传送) — Lua真源 MapProxy:handle_MSG_SC_MAP_CHANGE:
  194. # mapX=param1, mapY=param2(标准布局)。body=encode6(JSON){MapName,MapId,...}。
  195. # 旧实现不解析 → me['map'] 残留出生图'3', 比奇走路 body 全发'3'被拒
  196. # (run5-11 站桩根因); 坐标误用 recog/p1 也修(p1=X, p2=Y&0x7FF)。
  197. j = self._jbody(body) or {}
  198. old = self.me.get("mapname")
  199. if j.get("MapName"):
  200. self.me["mapname"] = j["MapName"]
  201. if j.get("MapId") is not None:
  202. self.me["map"] = str(j["MapId"])
  203. # 布局(SPY实捕 2026-09-23): recog=X, p1=Y(p2是bit11标志2048) —
  204. # 与 SC11/SC28/SC801 同族。旧按 p1=X,p2=Y 解析 → 坐标错位,
  205. # 后续走路链起点错 → SC28 拉回滞留带(等待图走不动的根因之一)
  206. nx = recog & 0xFFFF if recog > 0 else self.me["x"]
  207. ny = p1 & 0x7FF if (p1 & 0x800) else (p1 & 0xFFFF)
  208. self.me["x"], self.me["y"] = nx, ny
  209. self.me["last_x"], self.me["last_y"] = nx, ny
  210. self._last_sc634_t = time.time() # run17 修复: 防后续 SC50 立即覆盖
  211. if self.me["mapname"] != old:
  212. self.monsters.clear()
  213. self.npcs.clear()
  214. self._send_map_load(self.me["mapname"])
  215. log(f" [SC634] map={self.me['mapname']!r} MapId={self.me['map']!r} "
  216. f"@({nx},{ny})")
  217. ev.append(("map", self.me["mapname"], nx, ny))
  218. elif mid == 55:
  219. # 引擎:MSG_SC_MISSION_CHANGE — 任务状态推送(引擎必处理帧)
  220. # flag: 0未接取 1进行中 2完成未提交; 无head=移除。
  221. # 虚拟环境原则: 服务器推的任务体原样缓存, CS3031 提交时回构造它
  222. # (sniffer 截断无法存模板, 真源只能是服务器自己)。
  223. j = self._jbody(body) or {}
  224. if j.get("head"):
  225. old = self.missions.get(j.get("type"))
  226. self.missions[j["type"]] = j.get("flag", 1)
  227. self.mission_bodies[j["type"]] = j # 原样保留
  228. if old != j.get("flag", 1):
  229. ev.append(("mission", j.get("type"), j.get("flag", 1)))
  230. elif j.get("type") in self.missions:
  231. del self.missions[j["type"]]
  232. ev.append(("mission_done", j["type"]))
  233. elif mid in (11, 13):
  234. # SC11/13 = 全体 actor 移动广播 — Lua真源 netActorController:
  235. # handle_MSG_SC_ACTOR_WALK/RUN: p={x=param1, y=param2}(目标格),
  236. # body.UserID 查表→SetMoveTo(p) 且 SetConfirmed(true)。
  237. # 即: 对主玩家, SC11=服务器对 CS3011 的成功确认(目标格坐标)。
  238. j = self._jbody(body) or {}
  239. uid = str(j.get("UserID", ""))
  240. myuid = self.me.get("uid") or ""
  241. myid = str(self.me.get("id") or "")
  242. sid_short = self.me.get("sid_short") or ""
  243. if uid and (uid == myuid or uid == myid or (sid_short and uid == sid_short)):
  244. # 布局(2026-09-23 抓包定案): recog=X, p1=Y, p2=dir —
  245. # 旧按 p1=X,p2=Y 解析错位, 自回显永远匹配不上。
  246. x2 = recog & 0xFFFF if 0 <= recog <= 0xFFFF else recog & 0x7FF
  247. y2 = p1 & 0x7FF if (p1 & 0x800) else p1
  248. self.me["x"], self.me["y"] = x2, y2
  249. self.me["last_x"], self.me["last_y"] = x2, y2
  250. self.confirmed_at = (x2, y2, time.time())
  251. self._conf_pending = False # 移动已被服务器确认
  252. ev.append(("self_move", x2, y2))
  253. elif uid.isdigit() and int(uid) in self.monsters:
  254. y2 = p2 & 0x7FF if (p2 & 0x800) else p2
  255. self.monsters[int(uid)]["x"] = p1
  256. self.monsters[int(uid)]["y"] = y2
  257. self.monsters[int(uid)]["t"] = time.time()
  258. elif uid.isdigit() and int(uid) in self.npcs:
  259. y2 = p2 & 0x7FF if (p2 & 0x800) else p2
  260. self.npcs[int(uid)]["x"] = p1
  261. self.npcs[int(uid)]["y"] = y2
  262. elif mid == 14:
  263. # SC14 攻击广播 — cap3 实证: {"UserID":"<攻击者ID>","X":..,"Y":..}。
  264. # 三用: ①怪打人→怪雷达补录; ②自ID学习(攻击者坐标==我刚攻击的位置
  265. # →该 UserID 是自己的短会话ID, SC11 自回显匹配用); ③攻击确认。
  266. j = self._jbody(body) or {}
  267. uid = str(j.get("UserID", ""))
  268. x, y = int(j.get("X", 0)), int(j.get("Y", 0))
  269. y2 = y & 0x7FF if (y & 0x800) else y
  270. # 自ID学习: 我发出攻击后 ~2s 内, 广播坐标==我攻击时的位置 → 是我
  271. atk = getattr(self, "_my_attack_echo", None)
  272. if atk and time.time() - atk[2] < 2.0 and abs(x - atk[0]) <= 1 and abs(y2 - atk[1]) <= 1:
  273. if self.me.get("sid_short") != uid:
  274. self.me["sid_short"] = uid
  275. ev.append(("self_id_learned", uid))
  276. atk = None
  277. self._my_attack_echo = None
  278. elif uid == getattr(self.me, "sid_short", None):
  279. self.me["x"], self.me["y"] = x, y2
  280. ev.append(("self_move_atk", x, y2))
  281. elif uid.isdigit() and int(uid) < 1000000 and y2 > 50:
  282. aid = int(uid)
  283. if aid not in self.monsters:
  284. self.monsters[aid] = {"x": x, "y": y2, "race": 1049,
  285. "name": uid, "die": False, "t": time.time()}
  286. ev.append(("radar_mon", aid, x, y2))
  287. else:
  288. self.monsters[aid]["x"], self.monsters[aid]["y"] = x, y2
  289. self.monsters[aid]["t"] = time.time()
  290. elif mid in (10, 50):
  291. # SC10/SC50 进视野 — 分类对照 gameMapController.converActorData 真源:
  292. # recog = race | raceImg<<16, p2 = X | Y<<16, p3 = type | Dir<<16
  293. # 实测 p3 常为 0xFF/0x100(type 位与 Lua 矛盾) → 怪物判定改用 race 段
  294. # (>1000=怪, 实测新手怪 1049) + body cs 状态位(bit2=躺尸 bit1=骨头)。
  295. x, y = lo16(p2), hi16(p2)
  296. j = self._jbody(body) or {}
  297. cs = j.get("cs", 0) or 0
  298. die = bool(cs & 0b100) or bool(cs & 0b10)
  299. info = j.get("info")
  300. if mid == 50:
  301. self.me["id"] = p1
  302. if info:
  303. self.me["uid"] = str(info[0])
  304. # 关键修复(run17/18/19): SC634 后立即来的 SC50 位置经常错的(服务器
  305. # 传送动画完成前就广播新图SC50,实际玩家还没落到p1/p2指定点)。SC28才是
  306. # 真源 — 后到的SC28会把bot拉回(621,6)等真实坐标,导致 bot 用错误位置
  307. # 走路/攻击被 SC28 反复拒绝。
  308. # 处理: SC634 后 5s 内不更新 me['x'],me['y'],保留 SC634 设的位置。
  309. # run17 修复: SC634 后 5s 内不更新 me['x'],me['y'],保留 SC634 设的位置
  310. if p2 != 0:
  311. last_sc634 = getattr(self, "_last_sc634_t", 0)
  312. since = time.time() - last_sc634 if last_sc634 else 999
  313. if not (last_sc634 and since < 5.0):
  314. self.me["x"], self.me["y"] = x, y
  315. ev.append(("me_born", self.me["x"], self.me["y"]))
  316. elif lo16(recog) > 1000:
  317. self.monsters[p1] = {
  318. "x": x, "y": y, "race": lo16(recog),
  319. "name": (info[2] if info and len(info) > 2 else str(p1)),
  320. "die": die, "t": time.time()}
  321. ev.append(("mon_in", p1, x, y))
  322. elif info and len(info) >= 2:
  323. # TypeIndex = nActorId = 帧头 param1 (Lua 真源 gameMapController
  324. # converActorData: jsonData.nActorId = msgHdr.param1 →
  325. # actorUtils.SetActorProperty → actor:SetTypeIndex)。
  326. # p3 lo16 是 jsonData.type(SetSubType), 实测常 0xFF — 旧代码误用
  327. # 导致 CS1010 index 错误, 服务器静默不传送(2026-09-23 zone19 实证)。
  328. type_index = p1
  329. self.npcs[p1] = {"aid": str(info[0]), "name": info[1],
  330. "index": type_index, "x": x, "y": y}
  331. ev.append(("npc_in", str(info[0]), info[1], p1, type_index, x, y))
  332. elif mid == 53:
  333. # 引擎:MSG_SC_PLAYER_HP_MP — 实测定案(diag): body=encode6(
  334. # "长uid&HP&maxHP&MP&maxMP|power"), UserID 是长uid(非短actorID)
  335. self.hp_samples = getattr(self, "hp_samples", [])
  336. if len(self.hp_samples) < 20:
  337. self.hp_samples.append(body[:80])
  338. myuid = self.me.get("uid") or ""
  339. myid = str(self.me.get("id") or "")
  340. for v in CombatBot._body_variants(body):
  341. try:
  342. s = v.decode("utf-8")
  343. parts = s.split("|")[0].split("&")
  344. if len(parts) >= 3 and parts[0] in (myuid, myid):
  345. self.hp, self.max_hp = int(parts[1]), int(parts[2])
  346. ev.append(("hp", self.hp, self.max_hp))
  347. break
  348. except Exception:
  349. continue
  350. elif mid == 32:
  351. # SC32 死亡倒下 — Lua真源 handle_DeathFallDown: body=死亡actorID字符串,
  352. # 主玩家/怪共用此帧! 主玩家死亡→DIE状态, 等SC27复活/SC28回城拉回。
  353. raw = body.decode("utf-8", "replace").strip()
  354. myuid = self.me.get("uid") or ""
  355. myid = str(self.me.get("id") or "")
  356. if raw and (raw == myuid or raw == myid):
  357. self.dead = True
  358. ev.append(("me_die",))
  359. try:
  360. aid = int(raw)
  361. except ValueError:
  362. j = self._jbody(body)
  363. if isinstance(j, dict):
  364. try:
  365. aid = int(j.get("UserID", 0) or j.get("Id", 0) or 0)
  366. except (TypeError, ValueError):
  367. aid = 0
  368. elif isinstance(j, int):
  369. aid = j
  370. else:
  371. aid = 0
  372. if aid and aid in self.monsters:
  373. self.monsters[aid]["die"] = True
  374. # 击杀归属: SC32 是全区广播(不分谁杀)。只计"我近期攻击过且未死"
  375. # 的目标 — run6 实证: 视野内他人杀怪会让裸计数虚高(报2杀实际1杀)。
  376. atk = getattr(self, "_my_hits", {})
  377. last_hit_t = atk.get(aid, 0)
  378. if last_hit_t and time.time() - last_hit_t < 8.0:
  379. self.mon_kills += 1
  380. ev.append(("my_kill", aid))
  381. else:
  382. ev.append(("mon_die_other", aid))
  383. ev.append(("mon_die", aid))
  384. elif mid == 44:
  385. # SC44 经验(Lua: MSG_SC_PLAYER_EXP) — body=JSON{curr,changed,isNG}
  386. # (PlayerPropertyProxy:handle_MSG_SC_PLAYER_EXP, Run 16 实测 curr/变更字段名)
  387. j = self._jbody(body) or {}
  388. if isinstance(j, dict):
  389. new_exp = j.get("curr") or j.get("CurExp") or j.get("exp")
  390. changed = j.get("changed") or j.get("Changed") or 0
  391. if new_exp is not None:
  392. old_exp = getattr(self, "_exp_total", None)
  393. self._exp_total = int(new_exp)
  394. delta = int(changed) if changed else 0
  395. if old_exp is not None and delta > 0:
  396. log(f" [EXP] +{delta} → total={new_exp}")
  397. elif old_exp is None:
  398. log(f" [EXP] 当前={new_exp} (无变更)")
  399. ev.append(("exp", new_exp, delta))
  400. else:
  401. log(f" [SC44] body={j}")
  402. elif mid == 200:
  403. # SC200 背包加物品(Lua: MSG_SC_BAG_ADD_ITEM) — 怪物掉落
  404. j = self._jbody(body) or {}
  405. if isinstance(j, dict):
  406. name = j.get("Name") or j.get("name") or "?"
  407. count = j.get("Count") or j.get("count") or 1
  408. stdmode = j.get("StdMode") or j.get("stdMode") or 0
  409. log(f" [掉落] 背包+{name} ×{count} StdMode={stdmode}")
  410. self._drops = getattr(self, "_drops", 0) + 1
  411. ev.append(("bag_add", name, count))
  412. else:
  413. # 尝试直接用body字符串
  414. raw = body.decode("utf-8", "replace").strip()
  415. if raw:
  416. log(f" [掉落] SC200 body={raw[:80]}")
  417. self._drops = getattr(self, "_drops", 0) + 1
  418. elif mid == 610:
  419. # SC610 地图物品批量显示(Lua: MSG_SC_MAP_ITEM_DROP_BATCH) — 怪物掉落物在地上
  420. j = self._jbody(body)
  421. if isinstance(j, dict):
  422. items = j.get("items") or j.get("Items") or j.get("itemList") or []
  423. if items:
  424. for it in items[:5]:
  425. if isinstance(it, dict):
  426. log(f" [地面物品] {it.get('name') or it.get('Name') or '?'} @"
  427. f"({it.get('x') or it.get('X')},{it.get('y') or it.get('Y')})")
  428. ev.append(("ground_drop",))
  429. elif mid == 643:
  430. # SC643 NPC对话(Lua真源 NPCProxy.handle_MSG_SC_NPC_TALK):
  431. # body=JSON{sMsg(SUI富文本), Name, UserID, index}。
  432. # sMsg 含 <Button|...|link=@命令>, 点击=CS1011{UserID,Act,index}。
  433. # content=="CloseNpcLayer" 表示对话被服务器关闭。
  434. j = self._jbody(body)
  435. if isinstance(j, dict) and j.get("sMsg"):
  436. ev.append(("npc_talk", j))
  437. elif mid == 27:
  438. # SC27 复活(Lua: MSG_SC_NET_PLAYER_REVIVE) — 清死亡态
  439. self.dead = False
  440. ev.append(("me_revive",))
  441. elif mid == 28:
  442. # SC28 拉回(Lua真源 MOVE_FAIL: p1/p2=服务器接受坐标, 客户端照收)。
  443. # 两类异常帧(run27/29 漂移链条定案):
  444. # a) X/Y 反序: (291,620)→报(618,1), 反转(1,618)才在杀怪区
  445. # b) (0,0) 清位帧: 传送后对旧走路帧的无效拉回, 坐标无意义
  446. # 处理: (0,0)忽略(等SC11自回显/SC51校正); 其余按"离旧位置更近"的
  447. # X/Y 解读采信, 防坐标污染。
  448. if p1 == 0 and p2 == 0:
  449. ev.append(("pull_back_zero_ignored",))
  450. else:
  451. # SC28 本义是"上一步被拒, 拉回最近接受格" → 距离必近(≤4)。
  452. # 跳变大的帧是传送前后服务器按旧位置结算的过期拒绝
  453. # (09:03 实证: 传回等待图(23,25)后0秒发步, 被按传送前(25,0)拒)
  454. # → 就近过滤丢弃, 真实位置由 SC11 自回显/SC51 维护。
  455. old = (self.me["x"], self.me["y"])
  456. y2 = p2 & 0x7FF if (p2 & 0x800) else p2
  457. near = [c for c in ((p1, y2), (y2, p1))
  458. if max(abs(c[0] - old[0]), abs(c[1] - old[1])) <= 4]
  459. if not near:
  460. self._sc28_drop = getattr(self, "_sc28_drop", 0) + 1
  461. if self._sc28_drop <= 5:
  462. log(f" [SC28] 丢弃脏帧 raw=({p1},{y2}) old={old}")
  463. ev.append(("pull_back_bogus_dropped",))
  464. else:
  465. best = near[0]
  466. self.me["x"], self.me["y"] = best
  467. self.me["dir"] = p3 & 0xFF
  468. # 服务器权威坐标校正: lastPoint 同步重置(后续 CS3011 p3 从这里续链)
  469. self.me["last_x"], self.me["last_y"] = best
  470. if getattr(self, "_sc28_log_n", 0) < 12:
  471. self._sc28_log_n = getattr(self, "_sc28_log_n", 0) + 1
  472. log(f" [SC28] 拉回 old={old} → server={best} "
  473. f"(raw p1={p1} p2={y2})")
  474. ev.append(("pull_back", best[0], best[1]))
  475. else:
  476. ev += super()._dispatch(mid, recog, p1, p2, p3, body)
  477. return ev
  478. # ---------- 真实客户端的反馈帧 ----------
  479. def _send_map_load(self, mapname):
  480. """传送/进图后回发 CS5012+CS5027(body=图名) — 引擎要求, 不发则无该图视野推送。
  481. 抓包实证 body 用协议名(比奇='0', 盟重='3', 等待图=中文名), 后跟一帧 body='cTh'。"""
  482. proto = proto_map_name({"mapname": mapname})
  483. for mid in (5012, 5027):
  484. self.send(mid, 0, 0, 0, self.sid, proto.encode())
  485. self.send(5012, 0, 0, 0, 32611848, b"cTh")
  486. self.me["mapname"] = mapname
  487. self.monsters.clear()
  488. self.npcs.clear()
  489. def _guess_map_name(self, x, y):
  490. """无预期目的地时按落点坐标猜协议图名(等待图y<50, 否则比奇)。"""
  491. if y < 50 and x < 50:
  492. return WAIT_MAP
  493. for fname, mname in (("0", "比奇大城"), ("3", "盟重土城")):
  494. m = _map_cached(fname)
  495. if m and m.in_bounds(x, y) and m.walkable(x, y):
  496. return mname
  497. return "0"
  498. def _send_good(self, x, y, d):
  499. """CS1: recog=新X p1=新Y p2=dir p3=32171624 tail=943 body=+GOOD/毫秒。"""
  500. self.good_ms += STEP_MS
  501. self.send(1, x & 0xFFFF, y & 0xFFFF, d, 32171624,
  502. f"+GOOD/{self.good_ms}".encode(), tail=943)
  503. # ---------- 客户端驱动走路(与真实客户端同构) ----------
  504. def step_move(self, dx, dy, run=True):
  505. """CS3011 走1格 — run5/6/8 三轮实证通过的布局(等待图走位+比奇杀怪):
  506. recog=pack_point(起点), p1=0, p2=dir, p3=pack_point(上一点), body=MapId
  507. run13 反证: recog=目标 的"读法"连等待图都被拒。
  508. 成功确认 = 收到自己 UserID 的 SC11(p1=X目标, p2=Y目标); 超时由
  509. walk 节奏控制(0.547s/步), 拒绝则 SC28 拉回并重置 last。"""
  510. d = next((i for i, (ox, oy) in enumerate(DIRS) if ox == dx and oy == dy), None)
  511. if d is None:
  512. return False
  513. x, y = self.me["x"], self.me["y"]
  514. nx, ny = x + dx, y + dy
  515. if not (0 <= nx <= 65535 and 0 <= ny <= 65535):
  516. return False
  517. lx, ly = self.me.get("last_x", x), self.me.get("last_y", y)
  518. body = proto_map_name(self.me).encode()
  519. self.send(3011, pack_point(x, y), 0, d, pack_point(lx, ly), body)
  520. if getattr(self, "_walk_log_n", 0) < 12:
  521. self._walk_log_n = getattr(self, "_walk_log_n", 0) + 1
  522. log(f" [WALK] 3011 recog=({x},{y}) p2=dir{d} p3=({lx},{ly}) body={body!r}")
  523. self.me["last_x"], self.me["last_y"] = x, y
  524. self.me["x"], self.me["y"], self.me["dir"] = nx, ny, d
  525. self._step_pending_ack = (dx, dy, time.time() + STEP_MS / 1000.0)
  526. self._last_step_t = time.time()
  527. return True
  528. def step_toward(self, tx, ty):
  529. """战斗走位也必须登记 CS1 步末确认 — run28/31 实证: 战斗循环只 pump 不发
  530. CS1 时, 服务器拒绝所有后续走路帧(SC28 拉回风暴, 角色原地站桩)。"""
  531. x0, y0 = self.me["x"], self.me["y"]
  532. arrived = super().step_toward(tx, ty)
  533. if (self.me["x"], self.me["y"]) != (x0, y0):
  534. self._step_pending_ack = (0, 0, time.time() + STEP_MS / 1000.0)
  535. self._last_step_t = time.time()
  536. return arrived
  537. def tick_step_ack(self):
  538. """步末 CS1 + 传送落地 CS1(延后1.5s, 对齐真实客户端时序)。
  539. 注意: CS1 是我们→服务器的 +GOOD 计数帧; 服务器→我们的行为确认也是 1 号帧
  540. (SC1), 两者同号不同向。SC1 未到超 2s 视为确认超时(Lua 客户端 -5s 兜底同款)。"""
  541. la = self._landing_ack_due
  542. if la and time.time() >= la[3]:
  543. self._send_good(la[0], la[1], la[2])
  544. self._landing_ack_due = None
  545. ca = self._step_pending_ack
  546. if ca and time.time() >= ca[2]:
  547. self._send_good(self.me["x"], self.me["y"], self.me["dir"])
  548. self._step_pending_ack = None
  549. def walk_step_toward(self, tx, ty, run=True):
  550. """单步走向目标: 主策略=贪心直线(抓包实证真实客户端就是这么走的, 全程直线
  551. (32,0)→(292,580), 由服务器 SC28 校正); 卡住才用 A*(文件网格可能与服务器
  552. 不一致 — 等待图服务器网格≠0139.map, 用户实证客户端有1k分辨率缩放)。
  553. 返回 (moved, arrived)。"""
  554. x, y = self.me["x"], self.me["y"]
  555. if abs(tx - x) <= 1 and abs(ty - y) <= 1:
  556. return False, True
  557. dx = max(-1, min(1, tx - x))
  558. dy = max(-1, min(1, ty - y))
  559. if self._stuck_steps() >= 4:
  560. nxt = self._astar_next(x, y, tx, ty)
  561. if nxt:
  562. dx = max(-1, min(1, nxt[0] - x))
  563. dy = max(-1, min(1, nxt[1] - y))
  564. else:
  565. dx, dy = self._sidestep(x, y, dx, dy)
  566. if dx and dy:
  567. m = self._current_map(x, y)
  568. if m and not (m.walkable(x + dx, y) or m.walkable(x, y + dy)):
  569. dx, dy = self._sidestep(x, y, dx, dy)
  570. moved = self.step_move(dx, dy, run=run)
  571. return moved, False
  572. def _stuck_steps(self):
  573. """最近连续无位移步数(SC28 拉回/走帧被拒都会体现为位置不动)。"""
  574. hist = getattr(self, "_pos_hist", None)
  575. if hist is None:
  576. hist = self._pos_hist = []
  577. hist.append((self.me["x"], self.me["y"]))
  578. if len(hist) > 6:
  579. self._pos_hist = hist[-6:]
  580. if len(self._pos_hist) < 3:
  581. return 0
  582. return 3 if len(set(self._pos_hist[-3:])) == 1 else 0
  583. def _current_map(self, x, y):
  584. fname = map_file_for(self.me.get("mapname") or "", x, y)
  585. return _map_cached(fname) if fname else None
  586. def _astar_next(self, x, y, tx, ty):
  587. m = self._current_map(x, y)
  588. if not m or not m.walkable(x, y):
  589. return None
  590. path = m.find_path(x, y, tx, ty)
  591. return path[1] if path and len(path) > 1 else None
  592. def _sidestep(self, x, y, dx, dy):
  593. """贪心/A* 都失败时的垂直绕行方向。"""
  594. m = self._current_map(x, y)
  595. for cand in ((0, -1), (0, 1), (-1, 0), (1, 0)):
  596. if m is None or m.walkable(x + cand[0], y + cand[1]):
  597. return cand
  598. return dx, dy
  599. # ---------- NPC 发现 ----------
  600. def find_guide_npc(self):
  601. """从视野表找新手接待NPC(名称含 接待/新手/指引)。"""
  602. for n in self.npcs.values():
  603. if any(k in n["name"] for k in ("接待", "新手", "指引")):
  604. return n
  605. return list(self.npcs.values())[0] if self.npcs else None
  606. def find_spawn_npc(self):
  607. """从视野表找【点我刷怪】NPC(用户实测: 比奇无怪时点它可刷出大量怪物)。"""
  608. for n in self.npcs.values():
  609. if "刷怪" in n["name"]:
  610. return n
  611. return None
  612. # ---------- NPC 对话(协议真源: NPCProxy.lua) ----------
  613. # CS1010 {UserID:cfgID, index:typeIndex} 打开对话 → SC643 {sMsg,UserID,index}
  614. # sMsg 是 SUI 富文本(LexicalHelper), 按钮 link=@命令 → 点击 =
  615. # CS1011 {UserID, Act:"@命令", index:SC643回传的index} (ExecuteWithJsonData)
  616. def npc_talk_open(self, npc, timeout=5):
  617. """点 NPC 打开对话, 返回 SC643 对话体(dict)或 None。"""
  618. body = json.dumps({"UserID": npc["aid"], "index": npc["index"]},
  619. separators=(",", ":")).encode()
  620. self.send(1010, 0, 0, 0, self.sid, body)
  621. deadline = time.time() + timeout
  622. while time.time() < deadline and self.alive:
  623. for e in self.pump(0.3):
  624. if e[0] == "npc_talk":
  625. return e[1]
  626. return None
  627. def npc_click_act(self, talk, act, timeout=5):
  628. """点击对话里的按钮(Act), 返回随后的 SC643(可能有新对话)或 None。"""
  629. payload = {"UserID": talk.get("UserID"), "Act": act,
  630. "index": talk.get("index")}
  631. self.send(1011, 0, 0, 0, self.sid,
  632. json.dumps(payload, separators=(",", ":")).encode())
  633. deadline = time.time() + timeout
  634. while time.time() < deadline and self.alive:
  635. for e in self.pump(0.3):
  636. if e[0] == "npc_talk":
  637. return e[1]
  638. return None
  639. @staticmethod
  640. def parse_sui_acts(smsg):
  641. """从 SC643 sMsg(SUI 富文本)提取按钮/文本的 link 命令。
  642. 格式(LexicalHelper 文档注释): <Button|...|link=@命令> / <Text|...|link=@命令>"""
  643. if not smsg:
  644. return []
  645. return re.findall(r"link=@([^\|<>]+)", smsg)
  646. # ============================================================================
  647. # 等待图走动模式(--mode wait) — 2026-09-23 cap_gold 双向抓包 + Lua/C++ 源码定案
  648. #
  649. # 抓包关键更正(旧 wait_wander6 "三帧组"理论证伪):
  650. # cap_gold 是 socket 级双向 hook(dir 字段全标 CS 系标记瑕疵):
  651. # - tail=817 的 m=1/m=28 帧是【服务器→客户端】方向:
  652. # SC1: recog=X, p1=Y, p2=dir, body=encode6("+GOOD/<ms>") 行为确认(落点)
  653. # SC28×2: recog=X, p1=Y 段起止点申报
  654. # (Lua 真源 gamePlayerController.lua:304 handle_MSG_SC_NET_PLAYER_MOVE_FAIL:
  655. # param1/param2=服务器接受坐标; :58 注册 SC1=MSG_SC_ACTION_CONFIREM_SUCCESS)
  656. # - 客户端走一步【只发一帧】CS3011:
  657. # recog=0, p1=dir, p2=pack_point(起点), p3=sid, tail=主链递增,
  658. # body=图名UTF-8; 起点=上一步 SC1 确认落点(链式咬合)
  659. # - 心跳 CS60000 主链 tail, p3=sid; 服务器回显 60000(tail=0,p3=负数)
  660. # - 全程无 CS5013(AutoMoveBegin 寻路会话理论证伪, 客户端从未发送)
  661. #
  662. # 解锁仪式(每段成功走动的前置, cap_gold t=4985/5312/5692 三段同构):
  663. # CS3031(任务体) → CS1011 @renwu_mov,1 → 服务器把角色从滞留带(y=0)传送
  664. # 到可走区权威落点(~(23,27), SC634/SC51 下发) → CS5012/5027(body=等待地图)
  665. # ×2组+cTh×2 → CS3013 首步(p2=权威落点) → SC1 确认 → CS3011 链(550ms/步)
  666. # 未做此仪式时服务器侧角色仍在滞留带 → SC28 拉回 y=0 全拒(run1 实证)
  667. # ============================================================================
  668. SERVERLIST_URL = "https://list-box.dhsf.xqhuyu.com/box_serverlist/206923.txt"
  669. def pick_latest_zone():
  670. """服务器列表里 state=1 且已开服的最新区(open_time 最大且<=now)。"""
  671. r = rq.get(SERVERLIST_URL, timeout=20)
  672. m = re.search(r"\{.*\}", r.text, re.S)
  673. if not m:
  674. raise SystemExit("[!] 区列表拉取失败")
  675. data = json.loads(m.group(0))
  676. best = None
  677. def walk(o):
  678. nonlocal best
  679. if isinstance(o, dict):
  680. sid_, st, ot = o.get("serverid"), o.get("state"), o.get("open_time")
  681. if sid_ and st == 1 and ot and ot <= time.time():
  682. nm = o.get("list_name") or o.get("server_name") or ""
  683. if best is None or ot > best[0]:
  684. best = (ot, nm, sid_)
  685. for v in o.values():
  686. walk(v)
  687. elif isinstance(o, list):
  688. for v in o:
  689. walk(v)
  690. walk(data)
  691. if not best:
  692. raise SystemExit("[!] 区列表解析失败: 无 state=1 已开区")
  693. log(f"[新区] {best[1]} serverid={best[2]} "
  694. f"开服={time.strftime('%Y-%m-%d %H:%M', time.localtime(best[0]))}")
  695. return best[2]
  696. class TrueWalkBot(PushBot):
  697. """等待图走动: 3011+CS1 每步, 位置以 SC1(tail=817,+GOOD回显)确认为真源。"""
  698. def __init__(self, *a, **kw):
  699. super().__init__(*a, **kw)
  700. self.good_ms = 98996961125 + random.randint(0, 5000)
  701. self.move_confirmed = 0
  702. self.move_rejected = 0
  703. self.last_confirmed_at = None
  704. self.chain_pos = None # 走链起点(上一确认落点)
  705. def _send_good(self, x, y, d):
  706. """客户端 CS1: recog=落点X, p1=落点Y, p2=dir, p3=32368232,
  707. body=encode6('+GOOD/<ms>'), tail=主链递增(黄金抓包 t=5692.99 tail=167 实证)。"""
  708. self.good_ms += random.randint(750, 1000)
  709. body = encode6(f"+GOOD/{self.good_ms}".encode())
  710. self.send(1, x & 0xFFFF, y & 0xFFFF, d, 32368232, body)
  711. def step_walk(self, d):
  712. """单步走动: 仅发 CS3011, 起点=当前链位(=server权威位置)。
  713. cap_wait 验证: 客户端每步单发 3011(p2=起点pack, p3=sid, body=等待地图12B),
  714. 服务器回 SC1(tail=817, +GOOD回显, recog=落点X, p1=落点Y) 或 SC28(拉回, param1=X,param2=Y)。
  715. """
  716. if self.chain_pos is None:
  717. self.chain_pos = (self.me["x"], self.me["y"])
  718. x, y = self.chain_pos
  719. dx, dy = DIRS[d]
  720. nx, ny = x + dx, y + dy
  721. self.send(3011, 0, d, pack_point(x, y), self.sid, WAIT_MAP.encode())
  722. self.chain_pos = (nx, ny)
  723. log(f" >> CS3011 起点=({x},{y})→dir{d}→({nx},{ny})")
  724. def _dispatch(self, mid, recog, p1, p2, p3, body):
  725. if mid == 28:
  726. # SC28 MOVE_FAIL (Lua gamePlayerController.lua:316): param1=X, param2=Y, param3=dir
  727. self.move_rejected += 1
  728. x, y = p1, p2
  729. log(f" << SC28 #{self.move_rejected} pos=({x},{y}) dir={p3} 链={self.chain_pos}")
  730. self.chain_pos = (x, y)
  731. self.me["x"], self.me["y"] = x, y
  732. return []
  733. ev = super()._dispatch(mid, recog, p1, p2, p3, body)
  734. if mid == 1:
  735. # SC1 行为确认 (Lua 316: netMessageDef:94 SC_ACTION_CONFIREM_SUCCESS):
  736. # 客户端发: recog=落点X, p1=落点Y, p2=dir, p3=32368232, body=encode6("+GOOD/<ms>")
  737. # 服务器回 SC1 也用 1 号帧, body=服务器确认的 +GOOD 回显, recog/p1/p2 同布局
  738. self.move_confirmed += 1
  739. self.me["x"], self.me["y"] = recog & 0xFFFF, p1 & 0xFFFF
  740. self.me["last_x"], self.me["last_y"] = self.me["x"], self.me["y"]
  741. self.chain_pos = (self.me["x"], self.me["y"])
  742. self.last_confirmed_at = (self.me["x"], self.me["y"], time.time())
  743. log(f" << SC{1 if False else 1} #{self.move_confirmed} ★确认 → "
  744. f"({self.me['x']},{self.me['y']})")
  745. return ev
  746. def enter_wait_world(s, jwt, uid, zone, name, port=0):
  747. """等待图模式专用进世界: TrueWalkBot + 心跳走 bot.send 主链 tail。"""
  748. for a in range(4):
  749. try:
  750. P.enter_game(s, jwt, zone)
  751. break
  752. except Exception:
  753. if a == 3:
  754. raise
  755. time.sleep(12)
  756. got = None
  757. for pt in ((port,) if port else (8021, 8061, 8001, 8041)):
  758. try:
  759. r = P.login_gate(zone, jwt, name, pt, uid)
  760. if r[0]:
  761. got = r
  762. break
  763. except SystemExit as e:
  764. log(f" 端口{pt}: {e}")
  765. except Exception as e:
  766. log(f" 端口{pt}: {str(e)[:60]}")
  767. if not got:
  768. raise SystemExit("[!] 登录服失败")
  769. key32, sid, rname, tkey, gp = got
  770. log(f"[连接] 登录端口OK 角色={rname} sid={sid} 游戏端口={gp}")
  771. g = P.enter_game_gate(zone, rname, tkey, sid, gp, uid)
  772. bot = TrueWalkBot(g, sid, rname)
  773. # 心跳经 bot.send: tail 与主链共用递增(黄金抓包实证: 主链 tail 全序连续,
  774. # 独立计数器 → 服务器判会话非法 → SC28 拒绝一切移动)
  775. P.heartbeat_loop(g, sid, sender=bot.send)
  776. t0 = time.time()
  777. while time.time() - t0 < 10:
  778. bot.pump(0.3)
  779. if 658 in bot.frame_log:
  780. break
  781. if 658 not in bot.frame_log:
  782. g.close()
  783. raise SystemExit("[!] SC658 超时, 世界未初始化")
  784. log("[初始化] SC658 到达, 发送17帧初始化")
  785. send_init_frames(bot, uid)
  786. for _ in range(24):
  787. bot.pump(0.5)
  788. if bot.me["id"] and bot.me.get("mapname"):
  789. break
  790. log(f"进世界: map={bot.me.get('mapname')} @{bot.me['x']},{bot.me['y']} "
  791. f"uid={bot.me.get('uid')}")
  792. return g, bot
  793. def run_wait_wander(s, jwt, uid, zone, name, port, minutes):
  794. """等待图走动(cap_wait 验证过的最小形态):
  795. 进世界后直接走链, 每步单发 CS3011, 服务器通过 SC1(确认, +GOOD回显) /
  796. SC28(MOVE_FAIL, 拉回) 告诉客户端真实位置。链位永远以服务端为准。
  797. 注意: 角色必须在可走区(0139.map 中 y>=3); 若角色服务端位置在滞留带 y0-2,
  798. 需先用 quest 流程 NPC 传比奇再 renwu_mov 回到可走区。"""
  799. g, bot = enter_wait_world(s, jwt, uid, zone, name, port)
  800. try:
  801. P.enable_auto_heal(g, bot.sid, want_on=True)
  802. log("喝药保护: on")
  803. # 等首批视野帧(SC10/50/51 稳定 server 位置)
  804. for _ in range(20):
  805. bot.pump(0.3)
  806. if bot.me.get("mapname") and bot.me["x"] + bot.me["y"] > 0:
  807. break
  808. # 把 SC1/SC28 收到的位置同步进 chain_pos
  809. if bot.me.get("mapname"):
  810. bot.chain_pos = (bot.me["x"], bot.me["y"])
  811. log("=" * 64)
  812. log(f"[走链] server起始位=({bot.me['x']},{bot.me['y']}) "
  813. f"map={bot.me.get('mapname')}")
  814. log("=" * 64)
  815. t_end = time.time() + minutes * 60
  816. last_step = 0.0
  817. rep = time.time()
  818. heading = 5
  819. while time.time() < t_end and bot.alive:
  820. bot.pump(0.05)
  821. now = time.time()
  822. if now - last_step >= 0.55:
  823. if bot.move_rejected > bot.move_confirmed:
  824. heading = random.choice([h for h in range(8) if h != heading])
  825. log(f"[换向] 拒绝({bot.move_rejected})>确认({bot.move_confirmed}) → dir={heading}")
  826. bot.step_walk(heading)
  827. last_step = now
  828. if now - rep >= 10:
  829. rep = now
  830. fl = sorted(bot.frame_log.items(), key=lambda kv: -kv[1])
  831. log(f"[STAT] pos=({bot.me['x']},{bot.me['y']}) "
  832. f"确认={bot.move_confirmed} 拒绝={bot.move_rejected} "
  833. f"SC1={bot.frame_log.get(1, 0)} SC28={bot.frame_log.get(28, 0)} "
  834. f"SC10={bot.frame_log.get(10, 0)} SC11={bot.frame_log.get(11, 0)} "
  835. f"SC50={bot.frame_log.get(50, 0)} Top8={fl[:8]}")
  836. log("=" * 64)
  837. log(f"[结束] pos=({bot.me['x']},{bot.me['y']}) "
  838. f"确认={bot.move_confirmed} 拒绝={bot.move_rejected}")
  839. fl = sorted(bot.frame_log.items(), key=lambda kv: -kv[1])
  840. log(f"收帧Top12: {fl[:12]}")
  841. return bot.move_confirmed > 0
  842. finally:
  843. g.close()
  844. def npc_talk_to_biqi(bot):
  845. """滞留带 → 点NPC → 传比奇(quest_bot3 实证链, 保留供比奇流程复用)。"""
  846. t0 = time.time()
  847. while time.time() - t0 < 8 and not bot.npcs:
  848. bot.pump(0.4)
  849. npc = bot.find_guide_npc()
  850. if not npc:
  851. return bot.me.get("mapname") == "比奇大城"
  852. aid = npc["aid"]
  853. for idx in [npc["index"], 35, 34]:
  854. bot.pending_map_ack = "比奇大城"
  855. bot.send(1010, 0, 0, 0, bot.sid, json.dumps(
  856. {"UserID": aid, "index": idx}, separators=(",", ":")).encode())
  857. bot.send(5012, 0, 0, 0, bot.sid, b"0")
  858. bot.send(5027, 0, 0, 0, bot.sid, b"0")
  859. t1 = time.time()
  860. while time.time() - t1 < 8 and bot.alive:
  861. evs = bot.pump(0.3)
  862. if bot.me.get("mapname") == "比奇大城":
  863. bot.pending_map_ack = None
  864. return True
  865. talk = next((e[1] for e in evs if e[0] == "npc_talk"), None)
  866. if talk:
  867. acts = bot.parse_sui_acts(str(talk.get("sMsg", "")))
  868. act = next((a for a in acts if a not in ("exit", "close")), None)
  869. if act:
  870. bot.npc_click_act(talk, act)
  871. bot.pending_map_ack = None
  872. if bot.me.get("mapname") == "比奇大城":
  873. return True
  874. return bot.me.get("mapname") == "比奇大城"
  875. def enter_world(s, jwt, uid, zone, name, port=0):
  876. """登录链 + 进世界(17帧初始化), 返回 (sock, bot)。失败重试对齐 combat_bot。"""
  877. for a in range(4):
  878. try:
  879. P.enter_game(s, jwt, zone)
  880. break
  881. except Exception:
  882. if a == 3:
  883. raise
  884. time.sleep(12)
  885. got = None
  886. for pt in ((port,) if port else (8021, 8061, 8001, 8041)):
  887. try:
  888. r = P.login_gate(zone, jwt, name, pt, uid)
  889. if r[0]:
  890. got = r
  891. break
  892. except SystemExit as e:
  893. log(f" 端口{pt}: {e}")
  894. except Exception as e:
  895. log(f" 端口{pt}: {str(e)[:60]}")
  896. if not got:
  897. raise SystemExit("[!] 登录服失败")
  898. key32, sid, rname, tkey, gp = got
  899. g = P.enter_game_gate(zone, rname, tkey, sid, gp, uid)
  900. bot = PushBot(g, sid, rname)
  901. P.heartbeat_loop(g, sid, bot.lock) # CS60000 常驻心跳(断=踢)
  902. t0 = time.time()
  903. while time.time() - t0 < 10:
  904. bot.pump(0.3)
  905. if 658 in bot.frame_log:
  906. break
  907. if 658 not in bot.frame_log:
  908. g.close()
  909. raise SystemExit("[!] SC658 超时, 世界未初始化")
  910. send_init_frames(bot, uid)
  911. for _ in range(24):
  912. bot.pump(0.5)
  913. if bot.me["id"] and bot.me.get("mapname"):
  914. break
  915. log(f"进世界: map={bot.me.get('mapname')} @{bot.me['x']},{bot.me['y']} uid={bot.me.get('uid')}")
  916. return g, bot
  917. def run_fsm(bot, g, kill_target, minutes):
  918. """主循环: 状态机由服务器推送驱动。"""
  919. deadline = time.time() + minutes * 60
  920. act_3199 = lambda act: bot.send(3199, 0, 0, 0, bot.sid, json.dumps(
  921. {"act": act, "index": 104, "subid": 2}, separators=(",", ":")).encode())
  922. # 喝药保护(真实客户端进图即开)
  923. state = P.enable_auto_heal(g, bot.sid, want_on=True)
  924. log(f"喝药保护: {state}")
  925. # ---- 阶段1: 任意出生图 — 对齐 cap3 真实客户端序列 ----
  926. # cap3 实证: CS3031(带完整任务体,富文本在 docs/cs3031_template.txt)
  927. # → CS1011(@renwu_mov,1) → CS5012/5027(等待地图)
  928. # → SC800(传送,UserID=自己) → CS1010(NPC,index=35) → 比奇
  929. mname = bot.me.get("mapname") or ""
  930. log(f"[1] 出生于 {mname} @{bot.me['x']},{bot.me['y']} — CS3031+@renwu_mov,1")
  931. # CS3031 任务提交体: 优先用服务器 SC55 推来的实时任务体(虚拟环境真源),
  932. # 否则退回抓包模板(docs/cs3031_template.txt, sniffer 截断 200/562B)
  933. if 1 in bot.mission_bodies:
  934. body3031 = json.dumps(bot.mission_bodies[1], separators=(",", ":")).encode()
  935. src = "SC55实时"
  936. else:
  937. tpl_path = os.path.join(HERE, "docs", "cs3031_template.txt")
  938. try:
  939. body3031 = open(tpl_path, "rb").read()
  940. except OSError:
  941. body3031 = json.dumps(
  942. {"offsetY": 0, "flag": 1, "type": 1,
  943. "body": {"content": "", "color": "#ffff00"},
  944. "head": {"content": "", "color": "#ffff00"}},
  945. separators=(",", ":")).encode()
  946. src = "模板"
  947. bot.send(3031, 0, 0, 0, bot.sid, body3031)
  948. log(f" CS3031 body 来源={src} len={len(body3031)}")
  949. bot.pump(0.5)
  950. bot.tick_step_ack()
  951. bot.send(1011, 0, 0, 0, bot.sid, json.dumps(
  952. {"Act": "@renwu_mov,1", "UserID": "999999999", "index": 999999999},
  953. separators=(",", ":")).encode())
  954. # cap3: 点击后客户端立即预判加载等待图(盟重→指引→等待地图的第一跳)
  955. if mname != WAIT_MAP:
  956. bot.pending_map_ack = WAIT_MAP
  957. bot.send(5012, 0, 0, 0, bot.sid, WAIT_MAP.encode())
  958. bot.send(5027, 0, 0, 0, bot.sid, WAIT_MAP.encode())
  959. bot.send(5012, 0, 0, 0, 32611848, b"cTh")
  960. bot.pump(0.5)
  961. bot.tick_step_ack()
  962. # 等待: SC801 传送(→等待图) 或 已在等待图无传送
  963. t0 = time.time()
  964. while time.time() - t0 < 12 and bot.alive:
  965. bot.pump(0.3)
  966. bot.tick_step_ack()
  967. if bot.teleports:
  968. break
  969. if bot.me.get("mapname") == WAIT_MAP and time.time() - t0 > 3:
  970. break
  971. log(f" renwu_mov: teleports={bot.teleports} → {bot.me.get('mapname')} @{bot.me['x']},{bot.me['y']}")
  972. bot.pending_map_ack = None
  973. mname = bot.me.get("mapname") or ""
  974. if mname == WAIT_MAP:
  975. # 等待图: 客户端自己走向新手接待NPC(服务器对距离宽容, 走不到也能点)
  976. log("[2] 等待图 — 找新手接待NPC ...")
  977. t0 = time.time()
  978. while time.time() - t0 < 8 and not bot.npcs:
  979. bot.pump(0.4)
  980. npc = bot.find_guide_npc()
  981. if npc:
  982. log(f" NPC {npc['name']} aid={npc['aid']} @({npc['x']},{npc['y']})")
  983. t_end = time.time() + 30
  984. while time.time() < t_end and bot.alive:
  985. bot.pump(0.1)
  986. bot.tick_step_ack()
  987. moved, arrived = bot.walk_step_toward(npc["x"], npc["y"], run=False)
  988. if arrived:
  989. break
  990. if bot.me.get("mapname") != WAIT_MAP:
  991. break
  992. time.sleep(max(0.0, STEP_MS / 1000.0 - (time.time() - bot._last_step_t)))
  993. npc = bot.find_guide_npc()
  994. aid = npc["aid"] if npc else None
  995. type_index = npc["index"] if npc else 35
  996. log(f" [NPC] 新手指引 aid={aid} type_index={type_index} @({npc['x'] if npc else '?'},{npc['y'] if npc else '?'})")
  997. # 用 NPC 实际的 type_index (从 p3 解析, p3 = type | Dir<<16, lo16 = typeIndex)
  998. # cap3 实证: 矿工 index=35 是当时 typeIndex 值; 萌新2814/不同角色可能不同
  999. # index 候选: 实测不同区/角色 NPC 的 typeIndex 不同(cap3=35, 逐鹿20区
  1000. # p3解析=192), 逐个尝试直到触发传送; 每次点击后若回 SC643 对话则点按钮。
  1001. idx_cands = [type_index] + [i for i in (35, 34) if i != type_index]
  1002. base_tp = bot.teleports
  1003. teleported = False
  1004. for idx_try in idx_cands:
  1005. bot.pending_map_ack = "比奇大城"
  1006. bot.send(1010, 0, 0, 0, bot.sid, json.dumps(
  1007. {"UserID": aid, "index": idx_try}, separators=(",", ":")).encode())
  1008. bot.send(5012, 0, 0, 0, bot.sid, b"0")
  1009. bot.send(5027, 0, 0, 0, bot.sid, b"0")
  1010. bot.send(5012, 0, 0, 0, 32611848, b"cTh")
  1011. log(f"[3] CS1010 已发(aid={aid},index={idx_try})+预判比奇ACK — 等传送 ...")
  1012. t0 = time.time()
  1013. clicks = 0
  1014. while time.time() - t0 < 12 and bot.alive:
  1015. evs = bot.pump(0.3)
  1016. bot.tick_step_ack()
  1017. if bot.teleports > base_tp or (bot.me.get("mapname") == "比奇大城"
  1018. and time.time() - t0 > 1.0):
  1019. teleported = True
  1020. break
  1021. talk = next((e[1] for e in evs if e[0] == "npc_talk"), None)
  1022. if talk is None:
  1023. continue
  1024. smsg = str(talk.get("sMsg", ""))
  1025. acts = bot.parse_sui_acts(smsg)
  1026. log(f" [NPC对话] UserID={talk.get('UserID')} index={talk.get('index')}")
  1027. log(f" [NPC对话] acts={acts}")
  1028. log(f" [NPC对话] sMsg={smsg[:500]}")
  1029. if clicks >= 3:
  1030. break
  1031. act = next((a for a in acts if a not in ("exit", "close", "guanbi")), None)
  1032. if act:
  1033. bot.npc_click_act(talk, act)
  1034. clicks += 1
  1035. log(f" [NPC对话] 已点 @{act} (第{clicks}次)")
  1036. bot.pending_map_ack = None
  1037. if teleported or bot.me.get("mapname") == "比奇大城":
  1038. break
  1039. log(f" index={idx_try} 未传送, 换下一候选 ...")
  1040. log(f" 传送后: {bot.me.get('mapname')} @{bot.me['x']},{bot.me['y']} 怪视野={len(bot.monsters)}")
  1041. # ---- 比奇: 落点(293,619)疑为安全区 → 走到杀怪区(292,580)再挂机(斗士抓包同款) ----
  1042. mname = bot.me.get("mapname") or ""
  1043. tx, ty = KILL_AREA
  1044. if abs(tx - bot.me["x"]) > 6 or abs(ty - bot.me["y"]) > 6:
  1045. log(f"[4] {mname} @{bot.me['x']},{bot.me['y']} — 走向杀怪区 {KILL_AREA}")
  1046. t_end = time.time() + 240
  1047. while time.time() < t_end and bot.alive:
  1048. bot.pump(0.1)
  1049. bot.tick_step_ack()
  1050. if bot.me.get("mapname") == WAIT_MAP:
  1051. log(" [!] 被传回等待图, 中止走路")
  1052. break
  1053. _, arrived = bot.walk_step_toward(tx, ty, run=True)
  1054. if arrived:
  1055. break
  1056. time.sleep(max(0.0, STEP_MS / 1000.0 - (time.time() - bot._last_step_t)))
  1057. log(f" 走路结束 pos=({bot.me['x']},{bot.me['y']}) 怪视野={len(bot.monsters)}")
  1058. # ---- 挂机杀怪 ----
  1059. # 机制结论(Lua源码+抓包): @startguaji/CS9102 只是开关声明, 打怪本体是
  1060. # 客户端 AI(AutoController BT链: 选怪→走位→CS3014)。bot 复刻这段 AI:
  1061. # 视野选怪(race>1000) → step_toward 走近 → CS3014 普攻, SC32 计杀。
  1062. log("[5] 挂机开启(SUI声明+客户端AI) ...")
  1063. act_3199("@startguaji")
  1064. bot.send(5286, 0, 0, 0, bot.sid)
  1065. bot.send(9102, 1, 0, 0, bot.sid)
  1066. t0 = time.time()
  1067. rep = 0.0
  1068. target = None
  1069. wander_dst = None
  1070. no_mon_since = None
  1071. next_attack_t = 0.0
  1072. spawn_npc_used = 0 # 点【点我刷怪】NPC 次数(防死循环)
  1073. while time.time() - t0 < 420 and bot.alive and time.time() < deadline:
  1074. bot.pump(0.15) # 高频收帧: 怪在动, 虚拟环境必须实时
  1075. bot.tick_step_ack() # CS1 步末确认(run31 实证: 不发=走路全拒)
  1076. k = bot.mon_kills
  1077. if getattr(bot, "dead", False):
  1078. # 主玩家死亡(run20 实证: 1级号被围殴致死, SC32 body=自己uid)。
  1079. # 真实客户端行为: 躺尸→回城复活(SC28 拉回复活点)。bot: 等复活,
  1080. # 复活后清怪表重走杀怪区。
  1081. if target is not None:
  1082. log(f" [!] 死亡(被 {target} 击杀) — 等待复活...")
  1083. target = None
  1084. bot.monsters.clear()
  1085. time.sleep(1.0)
  1086. continue
  1087. # --- 客户端 AI: AutoFindActorBehavior → AutoFindSkillBehavior ---
  1088. m = None
  1089. if target is not None and target in bot.monsters:
  1090. m = bot.monsters[target]
  1091. if m["die"]:
  1092. target, m = None, None
  1093. if m is None:
  1094. # 目标过滤: 曼哈顿距离≤15, y>50(比奇坐标系, 排除等待图残留伪目标)
  1095. cands = [(abs(mm["x"] - bot.me["x"]) + abs(mm["y"] - bot.me["y"]), aid)
  1096. for aid, mm in bot.monsters.items()
  1097. if not mm["die"] and mm["y"] > 50
  1098. and abs(mm["x"] - bot.me["x"]) + abs(mm["y"] - bot.me["y"]) <= 15]
  1099. if cands:
  1100. target = min(cands)[1]
  1101. m = bot.monsters[target]
  1102. wander_dst = None
  1103. no_mon_since = None
  1104. log(f" 选定目标 #{target} {m['name']} @({m['x']},{m['y']})")
  1105. if m is not None:
  1106. dist = max(abs(m["x"] - bot.me["x"]), abs(m["y"] - bot.me["y"]))
  1107. # 关键修复(run17): 把攻击半径放大到 dist<=3 (Lua 客户端物理攻击多数<=3格内有效);
  1108. # 走路仅在 dist=4-5 之间触发,且严格在 KILL_AREA 范围内。
  1109. # 这样绝大多数情况下 (怪物固定在 619-622 区域) 都不需要走路,避免 SC28 风暴
  1110. if dist <= 3 and time.time() >= next_attack_t:
  1111. bot.send_attack(target, m["x"], m["y"])
  1112. bot._my_hits = {**getattr(bot, "_my_hits", {}),
  1113. target: time.time()} # 击杀归属登记
  1114. bot._my_attack_echo = (bot.me["x"], bot.me["y"], time.time())
  1115. next_attack_t = time.time() + 0.65 # HitTime=650ms
  1116. elif 3 < dist <= 6 and time.time() >= next_attack_t - 0.30:
  1117. tx, ty = m["x"], m["y"]
  1118. myx, myy = bot.me["x"], bot.me["y"]
  1119. in_safe_zone = (270 <= tx <= 310 and 605 <= ty <= 640 and
  1120. 270 <= myx <= 310 and 605 <= myy <= 640)
  1121. if in_safe_zone and _walkable_at(tx, ty):
  1122. bot.step_toward(tx, ty)
  1123. next_attack_t = time.time() + 0.35
  1124. else:
  1125. log(f" [放弃目标] #{target} 移出安全区 @{tx},{ty} (我@{myx},{myy})")
  1126. target = None
  1127. next_attack_t = time.time() + 0.10
  1128. elif dist > 6:
  1129. log(f" [放弃目标] #{target} 太远 dist={dist}")
  1130. target = None
  1131. next_attack_t = time.time() + 0.10
  1132. else:
  1133. # 无怪 → 优先点【点我刷怪】NPC(用户实测可刷出大量怪), 次选游走找怪。
  1134. # NPC 对话要求距离≤8格(NPCLayerMediator.CheckCloseNpcTalkLayer),
  1135. # 超距先走近再对话。
  1136. live_near = [mm for mm in bot.monsters.values() if not mm["die"] and mm["y"] > 50]
  1137. if not live_near and spawn_npc_used < 3:
  1138. snpc = bot.find_spawn_npc()
  1139. if snpc is None:
  1140. bot.pump(1.0) # 视野推送可能未到, 再收 1s
  1141. snpc = bot.find_spawn_npc()
  1142. if snpc is not None:
  1143. dist_n = max(abs(snpc["x"] - bot.me["x"]), abs(snpc["y"] - bot.me["y"]))
  1144. if dist_n > 5:
  1145. bot.step_toward(snpc["x"], snpc["y"])
  1146. next_attack_t = time.time() + 0.35
  1147. else:
  1148. spawn_npc_used += 1
  1149. log(f" [刷怪] 点NPC «{snpc['name']}» aid={snpc['aid']} "
  1150. f"第{spawn_npc_used}次 @({snpc['x']},{snpc['y']})")
  1151. talk = bot.npc_talk_open(snpc)
  1152. if talk:
  1153. acts = bot.parse_sui_acts(talk.get("sMsg", ""))
  1154. log(f" [刷怪] SC643 对话 acts={acts[:5]} "
  1155. f"sMsg前120={str(talk.get('sMsg'))[:120]}")
  1156. # 点第一个非 exit/link 类命令(通常是"刷怪"本尊)
  1157. act = next((a for a in acts
  1158. if a not in ("exit", "renwu_mov")), None)
  1159. if act:
  1160. bot.npc_click_act(talk, act)
  1161. log(f" [刷怪] 已点 Act=@{act} — 等怪进视野 ...")
  1162. bot.pump(2.0)
  1163. no_mon_since = None
  1164. wander_dst = None
  1165. else:
  1166. log(" [刷怪] NPC 无对话回应")
  1167. continue
  1168. # 无怪游走找怪 — Lua真源 AssistProxy:checkAimlessMove 同款思路:
  1169. # 随机可走点走过去刷新视野。本地化: 小范围(±12格, 新手杀怪区就 ~10格),
  1170. # 且需稳定无怪 3s 才触发(传送落地后视野推送需要 1-2s, run38 实证
  1171. # 过早游走会背向怪群跑)。
  1172. # 关键修复(run15): 取消游走。视野无怪时仅原地等怪, 怪自然刷新。
  1173. # 原因: 游走半径±12会把bot带进(2,619)西城墙或东侧墙区,触发SC28拉回
  1174. if wander_dst is None and no_mon_since is None:
  1175. no_mon_since = time.time()
  1176. # 极端兜底: 60s 还无怪才尝试附近小范围(±3)一步试探
  1177. if wander_dst is None and no_mon_since and time.time() - no_mon_since > 60.0:
  1178. for _ in range(5):
  1179. nx = bot.me["x"] + random.randint(-3, 3)
  1180. ny = bot.me["y"] + random.randint(-3, 3)
  1181. if _walkable_at(nx, ny):
  1182. wander_dst = (nx, ny)
  1183. log(f" 游走找怪 → ({nx},{ny})")
  1184. break
  1185. no_mon_since = time.time() # 重置,避免连续触发
  1186. else:
  1187. no_mon_since = None
  1188. if wander_dst is not None:
  1189. _, arrived = bot.walk_step_toward(*wander_dst, run=True)
  1190. if arrived:
  1191. wander_dst = None
  1192. no_mon_since = None
  1193. next_attack_t = time.time() + 0.35
  1194. if time.time() - rep > 10:
  1195. rep = time.time()
  1196. near = [(aid, mm["x"], mm["y"]) for aid, mm in bot.monsters.items()
  1197. if mm["y"] > 50][:6]
  1198. drift = getattr(bot, "drift_log", [])
  1199. log(f"[FIGHT] pos=({bot.me['x']},{bot.me['y']}) map={bot.me.get('mapname')} kills={k} "
  1200. f"SC32={bot.frame_log.get(32, 0)} SC14={bot.frame_log.get(14, 0)} "
  1201. f"HP={bot.hp}/{bot.max_hp} 比奇怪={near}"
  1202. + (f" 漂移={drift[-3:]}" if drift else ""))
  1203. if kill_target and k >= kill_target:
  1204. act_3199("@stopguaji")
  1205. bot.send(5283, 0, 0, 0, bot.sid)
  1206. bot.send(9102, 2, 0, 0, bot.sid)
  1207. log(f"★ 击杀达标: {k}")
  1208. bot.state = "DONE"
  1209. break
  1210. fl = sorted(bot.frame_log.items(), key=lambda kv: -kv[1])
  1211. # 关键帧(经验/掉落/背包/战斗)单独打印
  1212. key_frames = [(m, c) for m, c in fl if m in (44, 200, 202, 203, 610, 611, 1001, 870, 871, 53, 14, 32)]
  1213. log(f"结束: kills={getattr(bot, 'mon_kills', 0)} map={bot.me.get('mapname')} "
  1214. f"@({bot.me['x']},{bot.me['y']}) EXP={getattr(bot, '_exp_total', '?')} "
  1215. f"掉落数={getattr(bot, '_drops', 0)}")
  1216. log(f" 关键帧(经验/掉落/战斗): {key_frames}")
  1217. log(f" Top10收帧: {fl[:10]}")
  1218. log(f" 所有收帧: {fl}")
  1219. return getattr(bot, "mon_kills", 0)
  1220. def main():
  1221. ap = argparse.ArgumentParser()
  1222. ap.add_argument("-u", "--user", default="longwu988")
  1223. ap.add_argument("-p", "--password", default="Longwu1988")
  1224. ap.add_argument("--mode", choices=("quest", "wait"), default="quest",
  1225. help="quest=新手任务杀怪, wait=等待图走动")
  1226. ap.add_argument("--zone", type=int, default=14492165,
  1227. help="0=自动选服务器列表最新已开区")
  1228. ap.add_argument("--name", default=None)
  1229. ap.add_argument("--port", type=int, default=0)
  1230. ap.add_argument("--kill", type=int, default=2)
  1231. ap.add_argument("--minutes", type=float, default=2)
  1232. args = ap.parse_args()
  1233. zone = pick_latest_zone() if args.zone == 0 else args.zone
  1234. name = args.name or ("萌新" + str(random.randint(1000, 9999)))
  1235. j = None
  1236. for a in range(5):
  1237. try:
  1238. s = rq.Session()
  1239. j, _ = L.do_login(s, args.user, args.password)
  1240. if j.get("code") == 200:
  1241. break
  1242. except Exception as e:
  1243. log(f"登录重试{a + 1}: {str(e)[:60]}")
  1244. time.sleep(12)
  1245. if not j or j.get("code") != 200:
  1246. raise SystemExit(f"[!] 登录失败: {(j or {}).get('msg', '网络异常')}")
  1247. jwt = j["data"]["token"]
  1248. uid = str(j["data"]["userInfo"]["id"])
  1249. if args.mode == "wait":
  1250. ok = run_wait_wander(s, jwt, uid, zone, name, args.port, args.minutes)
  1251. log(f"[完成] 等待图走动 {'成功' if ok else '未获服务器确认'} 区={zone} 角色={name}")
  1252. return
  1253. args.minutes = min(args.minutes, 2) # quest 模式快速测试上限(用户约定)
  1254. g, bot = enter_world(s, jwt, uid, zone, args.name if args.name else name, args.port)
  1255. try:
  1256. kills = run_fsm(bot, g, args.kill or None, args.minutes)
  1257. log(f"[完成] kills={kills}")
  1258. finally:
  1259. g.close()
  1260. if __name__ == "__main__":
  1261. main()