cortico 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/package.json +43 -0
  4. package/src/boot.ts +46 -0
  5. package/src/bot.ts +1342 -0
  6. package/src/config-file.ts +41 -0
  7. package/src/core/README.md +92 -0
  8. package/src/core/billing.ts +26 -0
  9. package/src/core/blobs.ts +106 -0
  10. package/src/core/bus.ts +311 -0
  11. package/src/core/config-schema.ts +201 -0
  12. package/src/core/config.ts +262 -0
  13. package/src/core/core.ts +699 -0
  14. package/src/core/cost.ts +244 -0
  15. package/src/core/event-store.ts +304 -0
  16. package/src/core/fork.ts +113 -0
  17. package/src/core/generation.ts +118 -0
  18. package/src/core/instance-lock.ts +215 -0
  19. package/src/core/ipc-logger.ts +105 -0
  20. package/src/core/language.ts +62 -0
  21. package/src/core/log-context.ts +38 -0
  22. package/src/core/loop.ts +1858 -0
  23. package/src/core/markers.ts +48 -0
  24. package/src/core/prefix.ts +121 -0
  25. package/src/core/run.ts +95 -0
  26. package/src/core/secrets.ts +18 -0
  27. package/src/core/session.ts +56 -0
  28. package/src/core/sessions.ts +261 -0
  29. package/src/core/state.ts +104 -0
  30. package/src/core/template.ts +72 -0
  31. package/src/core/timers.ts +129 -0
  32. package/src/core/tool-log.ts +135 -0
  33. package/src/core/transcript.ts +92 -0
  34. package/src/core/truncate.ts +121 -0
  35. package/src/core/types.ts +1140 -0
  36. package/src/core/usage-log.ts +79 -0
  37. package/src/core/util.ts +386 -0
  38. package/src/deploy-listing.ts +160 -0
  39. package/src/deploy.ts +162 -0
  40. package/src/extensions/README.md +72 -0
  41. package/src/extensions/dry-mount.ts +390 -0
  42. package/src/extensions/manifest.ts +153 -0
  43. package/src/extensions/runtime.ts +42 -0
  44. package/src/extensions.ts +519 -0
  45. package/src/launcher.ts +285 -0
  46. package/src/paths.ts +155 -0
  47. package/src/protocol/open-responses/LICENSE +201 -0
  48. package/src/protocol/open-responses/README.md +14 -0
  49. package/src/protocol/open-responses/context-helpers.ts +27 -0
  50. package/src/protocol/open-responses/context-log.ts +59 -0
  51. package/src/protocol/open-responses/context.ts +89 -0
  52. package/src/protocol/open-responses/generated.ts +115 -0
  53. package/src/protocol/open-responses/index.ts +33 -0
  54. package/src/protocol/open-responses/openapi.json +4230 -0
  55. package/src/protocol/open-responses/stream.ts +146 -0
  56. package/src/protocol/open-responses/tokens.ts +31 -0
  57. package/src/providers/README.md +86 -0
  58. package/src/providers/base.ts +111 -0
  59. package/src/providers/configuration.ts +97 -0
  60. package/src/providers/console/config.ts +21 -0
  61. package/src/providers/console/settings.ts +443 -0
  62. package/src/providers/console/strings.ts +74 -0
  63. package/src/providers/console/types.ts +11 -0
  64. package/src/providers/llamacpp/archive.ts +148 -0
  65. package/src/providers/llamacpp/catalog.ts +247 -0
  66. package/src/providers/llamacpp/config.ts +36 -0
  67. package/src/providers/llamacpp/console/client.ts +6 -0
  68. package/src/providers/llamacpp/console/models-panel.ts +120 -0
  69. package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
  70. package/src/providers/llamacpp/console/server.ts +153 -0
  71. package/src/providers/llamacpp/index.ts +95 -0
  72. package/src/providers/llamacpp/native.ts +51 -0
  73. package/src/providers/llamacpp/options.ts +117 -0
  74. package/src/providers/llamacpp/runtime-store.ts +139 -0
  75. package/src/providers/llamacpp/runtime.ts +214 -0
  76. package/src/providers/llamacpp/server.ts +286 -0
  77. package/src/providers/llamacpp/strings.ts +230 -0
  78. package/src/providers/openai-responses-compat/config.ts +20 -0
  79. package/src/providers/openai-responses-compat/index.ts +89 -0
  80. package/src/providers/openai-responses-compat/native.ts +127 -0
  81. package/src/providers/openai-responses-compat/strings.ts +19 -0
  82. package/src/providers/pricebook.ts +75 -0
  83. package/src/providers/registry.ts +137 -0
  84. package/src/providers/strings.ts +71 -0
  85. package/src/providers/transport/chat.ts +156 -0
  86. package/src/providers/transport/errors.ts +48 -0
  87. package/src/providers/transport/history.ts +68 -0
  88. package/src/providers/transport/native-input.ts +83 -0
  89. package/src/providers/transport/native-types.ts +13 -0
  90. package/src/providers/transport/response-assembly.ts +191 -0
  91. package/src/providers/transport/response-http.ts +196 -0
  92. package/src/providers/transport/response-meters.ts +27 -0
  93. package/src/providers/transport/responses-input.ts +56 -0
  94. package/src/web/README.md +84 -0
  95. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
  96. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
  97. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
  98. package/src/web/client/console-pages/builtins.ts +10 -0
  99. package/src/web/client/console-pages/context.ts +135 -0
  100. package/src/web/client/console-pages/host.ts +633 -0
  101. package/src/web/client/console-pages/loader.ts +132 -0
  102. package/src/web/client/console-pages/strings.ts +90 -0
  103. package/src/web/client/console-pages/tools/strings.ts +49 -0
  104. package/src/web/client/console-pages/tools/view.ts +215 -0
  105. package/src/web/client/core/api.ts +200 -0
  106. package/src/web/client/core/language.ts +56 -0
  107. package/src/web/client/core/lifecycle.ts +132 -0
  108. package/src/web/client/core/router.ts +199 -0
  109. package/src/web/client/core/stream.ts +157 -0
  110. package/src/web/client/core/websocket.ts +82 -0
  111. package/src/web/client/features/appearance/index.ts +333 -0
  112. package/src/web/client/features/appearance/strings.ts +87 -0
  113. package/src/web/client/features/config/index.ts +11 -0
  114. package/src/web/client/features/config/strings.ts +41 -0
  115. package/src/web/client/features/config/view.ts +428 -0
  116. package/src/web/client/features/core/events.ts +197 -0
  117. package/src/web/client/features/core/index.ts +254 -0
  118. package/src/web/client/features/core/run.ts +56 -0
  119. package/src/web/client/features/core/runlog.ts +214 -0
  120. package/src/web/client/features/core/sessions.ts +53 -0
  121. package/src/web/client/features/core/strings.ts +157 -0
  122. package/src/web/client/features/extensions/index.ts +422 -0
  123. package/src/web/client/features/extensions/strings.ts +164 -0
  124. package/src/web/client/features/feature.ts +93 -0
  125. package/src/web/client/features/live/context.ts +280 -0
  126. package/src/web/client/features/live/fork.ts +156 -0
  127. package/src/web/client/features/live/index.ts +392 -0
  128. package/src/web/client/features/live/onboarding.ts +113 -0
  129. package/src/web/client/features/live/protocol.ts +122 -0
  130. package/src/web/client/features/live/sessions.ts +71 -0
  131. package/src/web/client/features/live/status.ts +68 -0
  132. package/src/web/client/features/live/strings.ts +252 -0
  133. package/src/web/client/features/live/timeline.ts +590 -0
  134. package/src/web/client/features/prompts/editor.ts +292 -0
  135. package/src/web/client/features/prompts/index.ts +346 -0
  136. package/src/web/client/features/prompts/strings.ts +136 -0
  137. package/src/web/client/features/prompts/view.ts +256 -0
  138. package/src/web/client/features/providers/index.ts +100 -0
  139. package/src/web/client/features/providers/strings.ts +23 -0
  140. package/src/web/client/features/settings/general.ts +28 -0
  141. package/src/web/client/features/settings/index.ts +82 -0
  142. package/src/web/client/features/settings/strings.ts +33 -0
  143. package/src/web/client/features/storage/index.ts +1 -0
  144. package/src/web/client/features/storage/strings.ts +45 -0
  145. package/src/web/client/features/storage/view.ts +154 -0
  146. package/src/web/client/features/usage/chart.ts +486 -0
  147. package/src/web/client/features/usage/index.ts +407 -0
  148. package/src/web/client/features/usage/labels.ts +46 -0
  149. package/src/web/client/features/usage/range.ts +36 -0
  150. package/src/web/client/features/usage/state.ts +37 -0
  151. package/src/web/client/features/usage/strings.ts +191 -0
  152. package/src/web/client/features/usage/tooltip.ts +86 -0
  153. package/src/web/client/features/usage/types.ts +96 -0
  154. package/src/web/client/features/worlds/index.ts +424 -0
  155. package/src/web/client/features/worlds/strings.ts +121 -0
  156. package/src/web/client/main.ts +273 -0
  157. package/src/web/client/shell/avatar.ts +233 -0
  158. package/src/web/client/shell/index.ts +549 -0
  159. package/src/web/client/shell/strings.ts +89 -0
  160. package/src/web/client/theme/handoff.ts +43 -0
  161. package/src/web/client/theme/palette.ts +109 -0
  162. package/src/web/client/theme/registry.ts +130 -0
  163. package/src/web/client/theme/storage.ts +72 -0
  164. package/src/web/client/theme/strings.ts +123 -0
  165. package/src/web/client/theme/studio.ts +366 -0
  166. package/src/web/client/ui/actions.ts +109 -0
  167. package/src/web/client/ui/data.ts +185 -0
  168. package/src/web/client/ui/dom.ts +23 -0
  169. package/src/web/client/ui/fields.ts +191 -0
  170. package/src/web/client/ui/format.ts +75 -0
  171. package/src/web/client/ui/icons.ts +183 -0
  172. package/src/web/client/ui/images.ts +91 -0
  173. package/src/web/client/ui/index.ts +89 -0
  174. package/src/web/client/ui/lamp.ts +125 -0
  175. package/src/web/client/ui/log.ts +98 -0
  176. package/src/web/client/ui/overlay.ts +184 -0
  177. package/src/web/client/ui/page.ts +8 -0
  178. package/src/web/client/ui/prompt-input.tsx +311 -0
  179. package/src/web/client/ui/sheet.ts +131 -0
  180. package/src/web/client/ui/strings.ts +71 -0
  181. package/src/web/console-pages.ts +367 -0
  182. package/src/web/files.ts +107 -0
  183. package/src/web/path-picker.ts +296 -0
  184. package/src/web/public/index.html +17 -0
  185. package/src/web/public/styles.css +1715 -0
  186. package/src/web/server.ts +1883 -0
  187. package/src/web/shared/client-panel.ts +603 -0
  188. package/src/web/shared/console-protocol.ts +560 -0
  189. package/src/web/shared/css.d.ts +1 -0
  190. package/src/web/shared/path-picker.ts +23 -0
  191. package/src/web/shared/theme.ts +256 -0
  192. package/src/web/theme-store.ts +37 -0
  193. package/src/world.ts +449 -0
  194. package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
  195. package/src/worlds/bilibili/README.md +176 -0
  196. package/src/worlds/bilibili/audience-admission.ts +995 -0
  197. package/src/worlds/bilibili/client.ts +528 -0
  198. package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
  199. package/src/worlds/bilibili/config.ts +185 -0
  200. package/src/worlds/bilibili/console/client.ts +202 -0
  201. package/src/worlds/bilibili/definition.ts +35 -0
  202. package/src/worlds/bilibili/gift-frame.ts +131 -0
  203. package/src/worlds/bilibili/normalize.ts +452 -0
  204. package/src/worlds/bilibili/overlay/announcement.ts +76 -0
  205. package/src/worlds/bilibili/overlay/assets.ts +82 -0
  206. package/src/worlds/bilibili/overlay/model.ts +494 -0
  207. package/src/worlds/bilibili/overlay/project.ts +202 -0
  208. package/src/worlds/bilibili/overlay/server.ts +286 -0
  209. package/src/worlds/bilibili/overlay/types.ts +217 -0
  210. package/src/worlds/bilibili/overlay/web/app.js +690 -0
  211. package/src/worlds/bilibili/overlay/web/editor.css +297 -0
  212. package/src/worlds/bilibili/overlay/web/editor.html +102 -0
  213. package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
  214. package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
  215. package/src/worlds/bilibili/overlay/web/styles.css +258 -0
  216. package/src/worlds/bilibili/protobuf.ts +123 -0
  217. package/src/worlds/bilibili/wire.ts +94 -0
  218. package/src/worlds/bilibili/world.ts +1389 -0
  219. package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
  220. package/src/worlds/console-fixture/console/client.ts +72 -0
  221. package/src/worlds/console-fixture/world.ts +73 -0
  222. package/src/worlds/index.ts +15 -0
  223. package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
  224. package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
  225. package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
  226. package/src/worlds/minecraft/README.md +917 -0
  227. package/src/worlds/minecraft/blueprint-plan.ts +915 -0
  228. package/src/worlds/minecraft/blueprint-registry.ts +566 -0
  229. package/src/worlds/minecraft/blueprint-repair.ts +236 -0
  230. package/src/worlds/minecraft/blueprint-resource.ts +272 -0
  231. package/src/worlds/minecraft/blueprint.ts +492 -0
  232. package/src/worlds/minecraft/body-lease.ts +340 -0
  233. package/src/worlds/minecraft/bridge.ts +749 -0
  234. package/src/worlds/minecraft/check.ts +651 -0
  235. package/src/worlds/minecraft/chests.ts +292 -0
  236. package/src/worlds/minecraft/client-launch.ts +272 -0
  237. package/src/worlds/minecraft/client-options.ts +85 -0
  238. package/src/worlds/minecraft/client-skins.ts +164 -0
  239. package/src/worlds/minecraft/client-window.ps1 +91 -0
  240. package/src/worlds/minecraft/client.ts +423 -0
  241. package/src/worlds/minecraft/combat-context.ts +109 -0
  242. package/src/worlds/minecraft/combat.ts +1552 -0
  243. package/src/worlds/minecraft/config.ts +499 -0
  244. package/src/worlds/minecraft/console/access.ts +218 -0
  245. package/src/worlds/minecraft/console/client.ts +244 -0
  246. package/src/worlds/minecraft/console/log.ts +110 -0
  247. package/src/worlds/minecraft/console/mount.ts +420 -0
  248. package/src/worlds/minecraft/console/skin.ts +257 -0
  249. package/src/worlds/minecraft/console/style.css +154 -0
  250. package/src/worlds/minecraft/console/world.ts +449 -0
  251. package/src/worlds/minecraft/deaths.ts +135 -0
  252. package/src/worlds/minecraft/definition.ts +18 -0
  253. package/src/worlds/minecraft/engine-child.ts +274 -0
  254. package/src/worlds/minecraft/engine-ipc.ts +98 -0
  255. package/src/worlds/minecraft/entity-facts.ts +132 -0
  256. package/src/worlds/minecraft/escape.ts +407 -0
  257. package/src/worlds/minecraft/executor.ts +14494 -0
  258. package/src/worlds/minecraft/explored.ts +156 -0
  259. package/src/worlds/minecraft/geometry.ts +205 -0
  260. package/src/worlds/minecraft/goal-plan.ts +647 -0
  261. package/src/worlds/minecraft/item-break.ts +126 -0
  262. package/src/worlds/minecraft/item-facts.ts +146 -0
  263. package/src/worlds/minecraft/item-pick.ts +67 -0
  264. package/src/worlds/minecraft/level-dat.ts +176 -0
  265. package/src/worlds/minecraft/log.ts +132 -0
  266. package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
  267. package/src/worlds/minecraft/names.ts +377 -0
  268. package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
  269. package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
  270. package/src/worlds/minecraft/piglin.ts +49 -0
  271. package/src/worlds/minecraft/policy.ts +547 -0
  272. package/src/worlds/minecraft/precheck.ts +655 -0
  273. package/src/worlds/minecraft/proxy.ts +445 -0
  274. package/src/worlds/minecraft/ranged.ts +635 -0
  275. package/src/worlds/minecraft/readouts.ts +102 -0
  276. package/src/worlds/minecraft/round.ts +26 -0
  277. package/src/worlds/minecraft/search-observation.ts +92 -0
  278. package/src/worlds/minecraft/server-config.ts +467 -0
  279. package/src/worlds/minecraft/server.ts +658 -0
  280. package/src/worlds/minecraft/show.ts +84 -0
  281. package/src/worlds/minecraft/skills.ts +1763 -0
  282. package/src/worlds/minecraft/terrain.ts +1735 -0
  283. package/src/worlds/minecraft/window.ts +96 -0
  284. package/src/worlds/minecraft/works.ts +179 -0
  285. package/src/worlds/minecraft/world.ts +6438 -0
  286. package/src/worlds/qq/ENV_PROMPT.md +5 -0
  287. package/src/worlds/qq/config.ts +108 -0
  288. package/src/worlds/qq/console/client.ts +126 -0
  289. package/src/worlds/qq/console/events.ts +155 -0
  290. package/src/worlds/qq/console/gate.ts +232 -0
  291. package/src/worlds/qq/console/roster.ts +203 -0
  292. package/src/worlds/qq/console/style.css +65 -0
  293. package/src/worlds/qq/conversation.ts +28 -0
  294. package/src/worlds/qq/definition.ts +62 -0
  295. package/src/worlds/qq/driver.ts +375 -0
  296. package/src/worlds/qq/history-tools.ts +209 -0
  297. package/src/worlds/qq/normalize.ts +323 -0
  298. package/src/worlds/qq/qface-map.ts +282 -0
  299. package/src/worlds/qq/vision-prompt.ts +27 -0
  300. package/src/worlds/qq/vision.ts +642 -0
  301. package/src/worlds/qq/vlm.ts +142 -0
  302. package/src/worlds/qq/world.ts +1503 -0
  303. package/src/worlds/terminal/ENV_PROMPT.md +11 -0
  304. package/src/worlds/terminal/config.ts +9 -0
  305. package/src/worlds/terminal/definition.ts +14 -0
  306. package/src/worlds/terminal/world.ts +700 -0
  307. package/src/worlds/websearch/ENV_PROMPT.md +1 -0
  308. package/src/worlds/websearch/brave-client.ts +261 -0
  309. package/src/worlds/websearch/config.ts +75 -0
  310. package/src/worlds/websearch/definition.ts +20 -0
  311. package/src/worlds/websearch/world.ts +171 -0
@@ -0,0 +1,1552 @@
1
+ /**
2
+ * 战斗模式 · 档 1(常驻夜间自保)。
3
+ *
4
+ * 战术核 = 台架 v7(scratch/mc-bench/combat/,七轮 300+ 场:30 只僵尸清场、
5
+ * survive 补波 20 场零死亡),旋钮按「自保」配:被打或 3 格内有敌对才动手,
6
+ * 不主动招惹;血线 10 撤;追击 6 格;硬时长 30s;间距 2.6(故意不用台架的 2.88——
7
+ * 2.88 几乎无敌,画面上"站在三十只僵尸中间毫发无伤"比冷却完美更不像人,
8
+ * 2.6 会偶尔挨打,是"人味"旋钮,不是性能旋钮)。
9
+ *
10
+ * 这几条是拿数据换来的,别改(依据在 RESULTS.md / RESULTS2.md):
11
+ * 1. 打最近的 + 1.3 倍粘性。威胁排序实测有害(9 只混合怪群 0/3 全死 → 改最近 3/3)。
12
+ * **任何让她离开身上那只、穿过怪堆去打另一只的策略都输。**
13
+ * 2. 贴身 ≥2 只切横扫,否则暴击;冷却一律等满(≥84.8% 才有暴击/横扫/冲刺击退)。
14
+ * 3. 距离用「眼睛 → AABB 最近点」,上限 3.0(服务端判定),不是脚底距离。
15
+ * 4. 移动走上下文图(combat-context.ts):行为只往 interest/danger 图写,硬约束走
16
+ * masking。不要写成 if/else 模式机——台架前四轮每加一条战术就打断另一条,
17
+ * 根因是架构不是战术。
18
+ * 5. 接近卡住 1.5s 交给寻路器,够得着(≤3.2)立刻收回来自己打。
19
+ * 6. 前摇排进冷却窗口(prep):单独值 1.6×。
20
+ * 7. 亚格间距(spacing):贴上沿做比例控制,目标以 v 逼近就以 v 后退。再值 1.8×。
21
+ * (6+7 合起来把承伤/秒从 0.79 压到 0.27。)
22
+ *
23
+ * 生命周期:夺手不夺嘴——接管移动与出手,事件照常推给人格说话;mc_do 期间
24
+ * 入队不启动;任务队列**挂起**不是抢占,战后从断点续做(非幂等步不重跑)。
25
+ */
26
+ import type { Bot } from 'mineflayer';
27
+ import pathfinderPkg from 'mineflayer-pathfinder';
28
+ import { Vec3 } from 'vec3';
29
+ import type { Logger } from '../../core/types.ts';
30
+ import type { MinecraftLog } from './log.ts';
31
+ import {
32
+ HARD, blend, blur, decide, newMap, slotDir, write, writeSlot, type Map16,
33
+ } from './combat-context.ts';
34
+ import { HOSTILE, bestWeapon, dropOwnedGoal, meleeCooldownMs, releaseBody, setOwnedGoal } from './executor.ts';
35
+ import type { FightMode } from './policy.ts';
36
+ import { zhEntity, zhName } from './names.ts';
37
+ import { BURNING_BLOCKS, SCORCHING_FLOOR, bodyInWater, findBankCell, headInWater } from './terrain.ts';
38
+ import { piglinIsHostile } from './piglin.ts';
39
+ import {
40
+ RANGED_EXIT_RANGE,
41
+ chooseHybridWeapon,
42
+ inKiteRange,
43
+ type BowEvent,
44
+ type BowShotResult,
45
+ type HybridWeapon,
46
+ type RangedTarget,
47
+ } from './ranged.ts';
48
+
49
+ const { goals } = pathfinderPkg;
50
+
51
+ /** 眼高;距离全部从这里量 */
52
+ const EYE = 1.62;
53
+ /** 服务端攻击判定上限(player.entity_interaction_range) */
54
+ const REACH_MAX = 3.0;
55
+ /** 远程怪贴脸:骷髅 <4 格会后退,逼它的直线预判失效 */
56
+ const RANGED_STOP = 1.7;
57
+ /** 苦力怕引信触发 3 格,留 0.6 余量 */
58
+ const CREEPER_SAFE = 3.6;
59
+ /** 贴身几只就改横扫(台架:sweepCrowd 2 全面优于 3) */
60
+ const SWEEP_CROWD = 2;
61
+ const KB_CROWD = 2;
62
+ /** 第二近的怪超过这个距离就当「有空间」,可以暴击 */
63
+ const SOLO_SPACE = 6;
64
+ /** 间距控制的预判时长:目标以 v 逼近,就按 v·t 提前修正 */
65
+ const SPACE_PREDICT_S = 0.25;
66
+ /** 接近毫无进展这么久就交给寻路器 */
67
+ const STUCK_PATH_MS = 1_500;
68
+ /** E1:场上没怪之后再等这么久才收工(防"炸完→退出→新怪→再进入"抖动) */
69
+ const CLEAR_HOLD_MS = 3_000;
70
+ /** E5:连续这么久打不着又靠不近就收工 */
71
+ const UNREACHABLE_MS = 6_000;
72
+ /**
73
+ * 无实际进展的绝对时限;只有进入近战可达且可见范围或实际命中才重置。
74
+ * 目标移动导致的 reach 差分不重置此计时。
75
+ */
76
+ const UNREACHABLE_HARD_MS = 20_000;
77
+ /** 进场后刀和箭均未出手的时限;不依赖挥刀次数门槛。 */
78
+ const NO_SWING_MS = 15_000;
79
+ /** E8:够得着、刀也挥出去了,却这么久一下没打中,就是打不到它 */
80
+ const NO_DAMAGE_MS = 6_000;
81
+ /** E8 起判所需的挥刀数:刚接敌头两刀还没回音不算 */
82
+ const NO_DAMAGE_SWINGS = 3;
83
+ /** 战况事件的节流 */
84
+ const STATUS_EVERY_MS = 6_000;
85
+ /** 击杀事件合并窗口 */
86
+ const KILL_MERGE_MS = 2_000;
87
+ /** E4 战斗占比的滑动窗口 */
88
+ const BUSY_WINDOW_MS = 300_000;
89
+ /**
90
+ * `queue:"now"` 交还身体之后的宽限:这段时间内不自动进场。
91
+ * 没有它,250ms 巡检会在下一拍把刚开跑的那一单重新挂起 —— 交还就白交了。
92
+ */
93
+ const STAND_DOWN_GRACE_MS = 5_000;
94
+ /** 挥刀到目标掉血的判定窗口(一个网络往返);超窗的掉血算别人打的,不记进命中 */
95
+ const LANDED_WINDOW_MS = 400;
96
+ /** 撤退:最近威胁超过这个距离,并保持无伤一段时间,才算安全 */
97
+ const RETREAT_CLEAR_DIST = 16;
98
+ /** 距离安全与无伤都要连续守住这一段,过滤瞬时丢实体/箭还在飞的假脱离 */
99
+ const RETREAT_SAFE_HOLD_MS = 1_500;
100
+ /** 撤退净位移这么久没有增加,持续受击时就视为逃路已经证伪 */
101
+ const RETREAT_STALL_MS = 1_500;
102
+ /** 到时只升级策略,不能在威胁仍近时把撤退记成成功 */
103
+ const RETREAT_MAX_MS = 8_000;
104
+ /**
105
+ * 撤退中连续未受击的时限;到期且撤退已超时、无进展时交还身体。
106
+ * 再次受击仍由 onHurtBy 重新处理。
107
+ */
108
+ const RETREAT_IDLE_MS = 10_000;
109
+ /** E5/E8 收工后的主动进场冷却,防止近处不可达目标立即触发新战斗。 */
110
+ const STUCK_COOLDOWN_MS = 10_000;
111
+
112
+ /** 远程怪:该举盾、该贴脸 */
113
+ const RANGED = new Set(['skeleton', 'stray', 'bogged', 'pillager', 'witch', 'blaze', 'ghast', 'illusioner', 'breeze']);
114
+
115
+ /** E7:不交战,只跑 */
116
+ const NO_FIGHT = new Set(['warden', 'wither', 'ender_dragon']);
117
+
118
+ /** 主动进场高于脱战血线的余量,单位为生命点;普通受击入场不加此余量。 */
119
+ const ENGAGE_MARGIN = 3;
120
+
121
+ export interface CombatTuning {
122
+ enabled: boolean;
123
+ /** 触发半径:这么近才算"找上门",不主动招惹(档 1 没有先手) */
124
+ engageRadius: number;
125
+ /** 追击上限(格) */
126
+ chaseMax: number;
127
+ /** E3 硬时长闸(秒) */
128
+ maxSec: number;
129
+ /** 理想间距。2.88 几乎无敌;2.6 像个会玩的人 */
130
+ space: number;
131
+ /** E4:5 分钟滑窗里战斗占比超过这个百分数就强制冷却 */
132
+ busyRatio: number;
133
+ /** E4 触发后多少秒内不自动进入 */
134
+ cooldownSec: number;
135
+ /** 血线:低于它就收手撤退(与反射的脱战血线同源) */
136
+ fleeHealth: number;
137
+ /**
138
+ * 主动进场的条件(mc_policy 的 `fight`)。只接在 `watchTick` 那一条上:
139
+ * 不接 `onHurtBy`(挨打一律还手)、不接 E1–E9 任何一道退出闸。
140
+ */
141
+ fight: FightMode;
142
+ }
143
+
144
+ interface CombatSessionOptions {
145
+ getBot: () => Bot | null;
146
+ tuning: () => CombatTuning;
147
+ /** 环境自保(岩浆/溺水反射/主动传送)正在进行:比战斗优先,期间不进场、进了也让位 */
148
+ envBusy: () => boolean;
149
+ /**
150
+ * flee/surface/eat 任务正在逃生时,阻止近处敌对触发主动进场;
151
+ * 受击仍交给 onHurtBy 判定,战斗结束后从断点恢复任务。
152
+ */
153
+ taskEscaping?: () => boolean;
154
+ /** 主动 attack 已持有身体;被动三格巡检不得把同一场再接管一遍。 */
155
+ taskFighting?: () => boolean;
156
+ /**
157
+ * 夺手:冻结任务执行器(当前任务断点挂起,队列原样保留,新任务只收不跑)。
158
+ * `by` 是记进 `skill/aborted` 日志的抢占方,写清是哪一只怪触发的。
159
+ */
160
+ suspendTasks: (by: string) => void;
161
+ /** 还手:解冻并从断点续做;返回一句"接着做"的说明(没有挂起任务则 null) */
162
+ resumeTasks: () => string | null;
163
+ /**
164
+ * 事件出口。第一人称事实,不出现「战斗模式/脚本/接管」(闭包原则)。
165
+ * hurt=true 表示这句已讲明掉血来由,World 据此不再复述掉血播报。
166
+ */
167
+ emit: (text: string, urgent: boolean, hurt?: boolean) => void;
168
+ ranged?: CombatRangedActions;
169
+ diag?: MinecraftLog;
170
+ log: Logger;
171
+ }
172
+
173
+ export interface CombatRangedActions {
174
+ ready(bot: Bot): boolean;
175
+ active(): boolean;
176
+ shoot(target: RangedTarget, ownerToken: unknown): Promise<BowShotResult>;
177
+ abort(): void;
178
+ }
179
+
180
+ interface Foe {
181
+ id: number;
182
+ name: string;
183
+ ent: NonNullable<Bot['entities'][number]>;
184
+ pos: Vec3;
185
+ /** 眼睛到 AABB 最近点——服务端攻击判定用的就是这个 */
186
+ reach: number;
187
+ /** 水平距离,给站位用 */
188
+ flat: number;
189
+ }
190
+
191
+ type EndReason = 'clear' | 'flee' | 'timeout' | 'stuck' | 'mc_stop' | 'env' | 'death' | 'gone' | 'busy' | 'preempt';
192
+
193
+ /** 眼睛到实体 AABB 最近点的距离 */
194
+ function aabbReach(bot: Bot, e: { position: Vec3; height?: number; width?: number }): number {
195
+ const eye = bot.entity.position.offset(0, EYE, 0);
196
+ const w = (e.width ?? 0.6) / 2;
197
+ const h = e.height ?? 1.8;
198
+ const cx = Math.max(e.position.x - w, Math.min(eye.x, e.position.x + w));
199
+ const cy = Math.max(e.position.y, Math.min(eye.y, e.position.y + h));
200
+ const cz = Math.max(e.position.z - w, Math.min(eye.z, e.position.z + w));
201
+ return Math.hypot(eye.x - cx, eye.y - cy, eye.z - cz);
202
+ }
203
+
204
+ /** 从某只眼睛看不看得见这只怪(苦力怕引信全程要视线,断视线即熄) */
205
+ function hasLosFrom(bot: Bot, eye: Vec3, e: { position: Vec3; height?: number }): boolean {
206
+ try {
207
+ const to = e.position.offset(0, (e.height ?? 1.8) * 0.6, 0);
208
+ const d = to.minus(eye);
209
+ const len = d.norm();
210
+ if (len < 0.5) return true;
211
+ const hit = (bot.world as unknown as {
212
+ raycast(f: Vec3, dir: Vec3, range: number, matcher?: (b: unknown) => boolean): unknown;
213
+ }).raycast(eye, d.scaled(1 / len), len, (b) => Boolean(b) && (b as { boundingBox?: string }).boundingBox === 'block');
214
+ return !hit;
215
+ } catch {
216
+ return true;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * 档 1 战斗会话:触发、v7 战术核、退出闸门、事件面,一个 owner 管到底。
222
+ * 反射层的受击反击与它合并(combat.enabled 时反射不再自己抡),环境自保仍在
223
+ * 它之上(envBusy 让位)。
224
+ */
225
+ export class CombatSession {
226
+ private watch: ReturnType<typeof setInterval> | null = null;
227
+ private state: 'idle' | 'fighting' | 'retreating' = 'idle';
228
+ private bot: Bot | null = null;
229
+ private startedAt = 0;
230
+ private deadlineAt = 0;
231
+ private cooldownUntil = 0;
232
+ private cooldownNoticed = false;
233
+ /** E4 滑窗:每场战斗的 [开始,结束] */
234
+ private readonly busyLog: Array<{ from: number; to: number }> = [];
235
+ /** 打过我们的实体(末影人只有惹过我们才进目标池) */
236
+ private readonly provoked = new Set<number>();
237
+ /** 我们出手打过的:击杀归账 + World 的"它死了"播报让位给这里 */
238
+ private readonly struck = new Set<number>();
239
+ /** 死亡事件先于实体表清理;本场已经死亡的实体不得继续参与威胁判断。 */
240
+ private readonly deadIds = new Set<number>();
241
+ private kills = new Map<string, number>();
242
+ private killsPending = new Map<string, number>();
243
+ private lastKillEmitAt = 0;
244
+ private lastStatusAt = 0;
245
+ private emptySince = 0;
246
+ private retreatFrom: { x: number; z: number } | null = null;
247
+ private retreatStartedAt = 0;
248
+ private retreatWhy: 'flee' | 'mc_stop' | 'no-fight' = 'flee';
249
+ /** 在水里撤退时的登岸目标;上岸或转身还手后清掉 */
250
+ private retreatBank: { x: number; y: number; z: number } | null = null;
251
+ /** 这一场撤退里挨的打:逃不掉(挨打且怪还贴着)就转身还手的判据 */
252
+ private retreatHits = 0;
253
+ /** 最近一次撤退中受伤;安全结束必须在它之后守住无伤时窗 */
254
+ private retreatLastHurtAt = 0;
255
+ /** 威胁距离首次进入安全区的时刻;重新靠近就清零 */
256
+ private retreatSafeSince = 0;
257
+ /** 撤退净位移最近一次取得进展的时刻与位置 */
258
+ private retreatProgressAt = 0;
259
+ private retreatProgressPos: { x: number; y: number; z: number } | null = null;
260
+ /** 非可战目标/明确收手在超时后继续撤,只记一次策略升级 */
261
+ private retreatEscalated = false;
262
+ /** 空手低血挡下的那次转身还手,一场撤退只记一次(判据每 250ms 都会再成立一遍) */
263
+ private retreatBarehandNoted = false;
264
+ /** 撤退失败转身还手中:E2 血线对本场静默(血线的前提是"跑得掉",这里已经证伪) */
265
+ private desperate = false;
266
+
267
+ // —— v7 出手/移动状态
268
+ private lastSwingAt = 0;
269
+ private swingPhase: 'idle' | 'hop' | 'settle' = 'idle';
270
+ private phaseTargetId = -1;
271
+ private phaseUntil = 0;
272
+ private wtapUntil = 0;
273
+ private sprintSince = 0;
274
+ private shieldUp = false;
275
+ private shieldHoldUntil = 0;
276
+ private targetId = -1;
277
+ private pathing = false;
278
+ private pathUntil = 0;
279
+ private lastReach = 99;
280
+ private lastProgressAt = 0;
281
+ /** 上一次**真**进展(够得着+视线,或走到跟前)的时刻;reach 差分刷新不了这条 */
282
+ private lastRealProgressAt = 0;
283
+ private lastLandedAt = 0;
284
+ private readonly ctxI: Map16 = newMap();
285
+ private readonly ctxD: Map16 = newMap();
286
+ private readonly ctxPrevI: Map16 = newMap();
287
+ private readonly ctxPrevD: Map16 = newMap();
288
+ private readonly ctxTerrain: Map16 = newMap();
289
+ private ctxTerrainAt = 0;
290
+ private readonly ctxCreeper: Map16 = newMap();
291
+ private ctxCreeperAt = 0;
292
+ private coverCell: { x: number; y: number; z: number } | null = null;
293
+ private coverAt = 0;
294
+
295
+ /**
296
+ * 出手记账:挥了几次、其中几次让目标掉了血。`bot.attack()` 只管发包,够不够得着
297
+ * 由服务端说了算 —— 不分账的话「打了半天没打死」在日志上与「压根没碰到」同形。
298
+ */
299
+ private swings = 0;
300
+ private landed = 0;
301
+ private lastSwingTargetId = -1;
302
+ private hybridWeapon: HybridWeapon = 'melee';
303
+ private rangedOwner: object | null = null;
304
+ private rangedPendingOwner: object | null = null;
305
+ private nextRangedAt = 0;
306
+ private arrows = 0;
307
+ private rangedLanded = 0;
308
+ private lastRangedHitTargetId = -1;
309
+ private lastRangedHitAt = 0;
310
+
311
+ private readonly onTick = (): void => this.tick();
312
+ private readonly onDead = (e: { id?: number; name?: string; position?: Vec3 }): void => this.noteDead(e);
313
+ private readonly onFoeHurt = (e: { id?: number }, source?: { id?: number }): void => this.noteFoeHurt(e, source);
314
+ private readonly onSelfDeath = (): void => {
315
+ if (this.state !== 'idle') this.end('death');
316
+ else this.cancelRanged();
317
+ };
318
+
319
+ constructor(private readonly opts: CombatSessionOptions) {}
320
+
321
+ start(): void {
322
+ if (this.watch) return;
323
+ this.watch = setInterval(() => this.watchTick(), 250);
324
+ this.watch.unref?.();
325
+ }
326
+
327
+ stop(): void {
328
+ if (this.watch) clearInterval(this.watch);
329
+ this.watch = null;
330
+ if (this.state !== 'idle') this.end('gone');
331
+ else this.cancelRanged();
332
+ }
333
+
334
+ /** 正在打(或正在撤)。执行器的受理回执与 World 的播报口径都看它 */
335
+ get active(): boolean {
336
+ return this.state !== 'idle';
337
+ }
338
+
339
+ /** 这只怪的死由战斗会话来说,World 的「它死了」播报让位 */
340
+ claims(id: number): boolean {
341
+ return this.state !== 'idle' && this.struck.has(id);
342
+ }
343
+
344
+ /** 弓控制器的租约只在创建它的这一场战斗内有效。 */
345
+ ownsRanged(ownerToken: unknown): boolean {
346
+ return this.state === 'fighting' && ownerToken === this.rangedOwner;
347
+ }
348
+
349
+ /** 近战发包窗口优先,防止在途旧箭把刚落下的一刀归成远程命中。 */
350
+ acceptsRangedHit(targetId: number, at = Date.now()): boolean {
351
+ return this.rangedOwner !== null && !(
352
+ this.lastSwingTargetId === targetId && at - this.lastSwingAt <= LANDED_WINDOW_MS
353
+ );
354
+ }
355
+
356
+ onBowEvent(event: BowEvent): void {
357
+ if (event.kind === 'blocked' || !this.ownsRanged(event.ownerToken)) return;
358
+ if (event.kind === 'released') {
359
+ this.arrows += 1;
360
+ this.nextRangedAt = Math.max(this.nextRangedAt, event.at + 200);
361
+ return;
362
+ }
363
+ this.rangedLanded += 1;
364
+ this.landed += 1;
365
+ this.lastLandedAt = event.at;
366
+ this.lastRangedHitTargetId = event.targetId;
367
+ this.lastRangedHitAt = event.at;
368
+ this.struck.add(event.targetId);
369
+ }
370
+
371
+ onConnectionLost(): void {
372
+ if (this.state !== 'idle') this.end('gone');
373
+ else this.cancelRanged();
374
+ }
375
+
376
+ /**
377
+ * 反射层受击时转进来。返回 true = 会话接手(反射不再自己抡);
378
+ * false = 会话进不了场(关着/冷却/环境自保中),退回反射的降级行为。
379
+ */
380
+ onHurtBy(attackerId: number, name: string): boolean {
381
+ const t = this.opts.tuning();
382
+ if (!t.enabled) return false;
383
+ if (this.state === 'retreating') {
384
+ this.retreatHits += 1;
385
+ this.retreatLastHurtAt = Date.now();
386
+ this.retreatSafeSince = 0;
387
+ this.provoked.add(attackerId);
388
+ return true;
389
+ } // 已经在撤了,别叠加;伤害历史留在同一场里,逃不掉要转身
390
+ if (this.state === 'fighting') {
391
+ this.provoked.add(attackerId);
392
+ return true;
393
+ }
394
+ if (this.opts.envBusy()) return false;
395
+ // 头在水里不接手:水下近战是另一回事,交回 surface/防溺水那条线
396
+ const bot = this.opts.getBot();
397
+ if (bot && headInWater(bot)) return false;
398
+ // E7:打不过的,不进场,直接跑
399
+ if (NO_FIGHT.has(name)) {
400
+ this.bot = this.opts.getBot();
401
+ if (!this.bot) return false;
402
+ this.opts.suspendTasks(`战斗:${zhEntity(name)}打不过,撤退`);
403
+ this.hook(this.bot);
404
+ this.opts.emit(`是${zhEntity(name)}!这个打不过,快跑!(生命 ${this.hp()}/20)`, true, true);
405
+ this.beginRetreat('no-fight');
406
+ return true;
407
+ }
408
+ if (Date.now() < this.cooldownUntil) return false;
409
+ if (bot?.entity && !bestWeapon(bot) && !this.rangedReady(bot) && (bot.health ?? 20) < t.fleeHealth + ENGAGE_MARGIN) {
410
+ this.bot = bot;
411
+ this.opts.suspendTasks(`战斗:空手血 ${this.hp()},不还手,撤`);
412
+ this.hook(bot);
413
+ this.opts.emit(`空手,血只剩 ${this.hp()}/20 —— 这架不还手了,撤。`, true, true);
414
+ this.opts.diag?.write({
415
+ lane: 'combat', event: 'barehand-flee',
416
+ msg: `空手低血脱离:${zhEntity(name)}打过来,生命 ${this.hp()}/20 低于 ${t.fleeHealth + ENGAGE_MARGIN}`,
417
+ data: { foe: name, health: bot.health, line: t.fleeHealth + ENGAGE_MARGIN },
418
+ });
419
+ this.beginRetreat('flee');
420
+ return true;
421
+ }
422
+ this.provoked.add(attackerId);
423
+ return this.engage(name, true);
424
+ }
425
+
426
+ /** 250ms 巡检:3 格内有敌对就开打(不主动招惹,这是"找上门"的判据) */
427
+ private watchTick(): void {
428
+ if (this.state !== 'idle') return;
429
+ const t = this.opts.tuning();
430
+ // 她正在跑(flee/surface):不趁逃跑抢场——挨了打另说(onHurtBy 不看这道闸)
431
+ if (!t.enabled || this.opts.envBusy() || this.opts.taskEscaping?.() || this.opts.taskFighting?.()) return;
432
+ if (t.fight === 'off') return;
433
+ if (Date.now() < this.cooldownUntil) return;
434
+ const bot = this.opts.getBot();
435
+ if (!bot?.entity) return;
436
+ // armed 模式仅在背包有可用近战武器或远程武器就绪时主动进场。
437
+ if (t.fight === 'armed' && !bestWeapon(bot) && !this.rangedReady(bot)) return;
438
+ this.bot = bot;
439
+ const foes = this.scan(bot, t);
440
+ const near = foes.find((f) => f.reach <= t.engageRadius && !NO_FIGHT.has(f.name));
441
+ if (near) {
442
+ if (near.name === 'piglin') this.provoked.add(near.id);
443
+ this.engage(near.name, false);
444
+ }
445
+ }
446
+
447
+ /** 播报用的整数血量:向上取整与游戏 HUD 的半颗心一致,0.1 血活着就报 1,不报 0。判定一律用浮点原值。 */
448
+ private hp(): number {
449
+ return Math.ceil(this.bot?.health ?? 0);
450
+ }
451
+
452
+ private engage(firstName: string, hurt: boolean): boolean {
453
+ const bot = this.opts.getBot();
454
+ if (!bot?.entity) return false;
455
+ // 血线不足时转入撤退,避免 250ms 巡检反复尝试进场。
456
+ const line = this.opts.tuning().fleeHealth + (hurt ? 0 : ENGAGE_MARGIN);
457
+ if ((bot.health ?? 20) < line) {
458
+ this.bot = bot;
459
+ this.opts.suspendTasks(`战斗:血只剩 ${this.hp()},不接这架`);
460
+ this.hook(bot);
461
+ this.opts.emit(`血只剩 ${this.hp()}/20,这架不接了,先拉开。`, true, hurt);
462
+ this.opts.diag?.write({
463
+ lane: 'combat', event: 'engage-refused',
464
+ msg: `血线挡下进场:${firstName}${hurt ? '(挨了打)' : '(进了 3 格圈)'},` +
465
+ `生命 ${this.hp()}/20 低于入场线 ${line}`,
466
+ data: { first: firstName, hurt, health: bot.health, line },
467
+ });
468
+ this.beginRetreat('flee');
469
+ return true;
470
+ }
471
+ // E4:5 分钟滑窗里战斗占比超标 → 冷却,不自动进场
472
+ if (this.busyRatioNow() > this.opts.tuning().busyRatio / 100) {
473
+ this.cooldownUntil = Date.now() + this.opts.tuning().cooldownSec * 1000;
474
+ if (!this.cooldownNoticed) {
475
+ this.cooldownNoticed = true;
476
+ this.opts.emit('这地方怪一波接一波,打不完,我得先离开这一片。', true, false);
477
+ }
478
+ return false;
479
+ }
480
+ this.cooldownNoticed = false;
481
+ this.bot = bot;
482
+ this.state = 'fighting';
483
+ const now = Date.now();
484
+ this.startedAt = now;
485
+ this.deadlineAt = now + this.opts.tuning().maxSec * 1000;
486
+ this.lastStatusAt = now;
487
+ this.emptySince = 0;
488
+ this.lastProgressAt = now;
489
+ this.lastRealProgressAt = now;
490
+ this.lastLandedAt = now;
491
+ this.lastReach = 99;
492
+ this.targetId = -1;
493
+ this.swingPhase = 'idle';
494
+ this.desperate = false;
495
+ this.retreatBank = null;
496
+ this.pathing = false;
497
+ this.kills = new Map();
498
+ this.killsPending = new Map();
499
+ this.struck.clear();
500
+ this.deadIds.clear();
501
+ this.swings = 0;
502
+ this.landed = 0;
503
+ this.lastSwingTargetId = -1;
504
+ this.hybridWeapon = 'melee';
505
+ this.rangedOwner = {};
506
+ this.rangedPendingOwner = null;
507
+ this.nextRangedAt = now;
508
+ this.arrows = 0;
509
+ this.rangedLanded = 0;
510
+ this.lastRangedHitTargetId = -1;
511
+ this.lastRangedHitAt = 0;
512
+ this.ctxPrevI.fill(0);
513
+ this.ctxPrevD.fill(0);
514
+ this.opts.suspendTasks(`战斗:${hurt ? '被' : ''}${zhEntity(firstName)}${hurt ? '打了' : '贴到跟前'}`);
515
+ this.hook(bot);
516
+ const weapon = bestWeapon(bot);
517
+ if (weapon) void bot.equip(weapon, 'hand').catch(() => undefined);
518
+ const held = weapon ? `,手里是${zhName(weapon.name)}` : ',手边没趁手的家伙';
519
+ this.opts.emit(
520
+ hurt
521
+ ? `有只${zhEntity(firstName)}打过来了,我抄家伙还手!(生命 ${this.hp()}/20${held})`
522
+ : `${zhEntity(firstName)}贴到跟前了,我先动手!(生命 ${this.hp()}/20${held})`,
523
+ true, hurt,
524
+ );
525
+ this.opts.diag?.write({
526
+ lane: 'combat', event: 'engage',
527
+ msg: `开打:${firstName}${hurt ? '(挨了打)' : '(进了 3 格圈)'},生命 ${this.hp()}/20`,
528
+ data: { first: firstName, hurt, health: this.bot.health },
529
+ });
530
+ return true;
531
+ }
532
+
533
+ private hook(bot: Bot): void {
534
+ bot.on('physicsTick', this.onTick);
535
+ bot.on('entityDead' as never, this.onDead as never);
536
+ bot.on('entityHurt' as never, this.onFoeHurt as never);
537
+ bot.on('death', this.onSelfDeath);
538
+ }
539
+
540
+ private unhook(): void {
541
+ const bot = this.bot;
542
+ if (!bot) return;
543
+ bot.removeListener('physicsTick', this.onTick);
544
+ bot.removeListener('entityDead' as never, this.onDead as never);
545
+ bot.removeListener('entityHurt' as never, this.onFoeHurt as never);
546
+ bot.removeListener('death', this.onSelfDeath);
547
+ }
548
+
549
+ /** 刚挥的那一刀落没落在目标身上:掉血在挥刀后一个往返内到,超窗的算别人打的 */
550
+ private noteFoeHurt(e: { id?: number }, source?: { id?: number }): void {
551
+ if (this.state === 'idle' || typeof e.id !== 'number') return;
552
+ if (typeof source?.id === 'number' && source.id !== this.bot?.entity.id) return;
553
+ if (e.id === this.lastRangedHitTargetId && Date.now() - this.lastRangedHitAt <= LANDED_WINDOW_MS) {
554
+ this.lastRangedHitTargetId = -1;
555
+ return;
556
+ }
557
+ if (e.id !== this.lastSwingTargetId) return;
558
+ if (Date.now() - this.lastSwingAt > LANDED_WINDOW_MS) return;
559
+ this.landed += 1;
560
+ this.lastLandedAt = Date.now();
561
+ this.lastSwingTargetId = -1; // 一刀只记一次命中
562
+ }
563
+
564
+ private noteDead(e: { id?: number; name?: string }): void {
565
+ if (this.state === 'idle' || typeof e.id !== 'number') return;
566
+ this.deadIds.add(e.id);
567
+ if (!this.struck.has(e.id)) return;
568
+ this.struck.delete(e.id);
569
+ const name = e.name ?? 'unknown';
570
+ this.kills.set(name, (this.kills.get(name) ?? 0) + 1);
571
+ this.killsPending.set(name, (this.killsPending.get(name) ?? 0) + 1);
572
+ const now = Date.now();
573
+ if (now - this.lastKillEmitAt < KILL_MERGE_MS) return;
574
+ this.lastKillEmitAt = now;
575
+ const parts = [...this.killsPending].map(([n, c]) => `${zhEntity(n)}${c > 1 ? `×${c}` : ''}`);
576
+ this.killsPending = new Map();
577
+ const t = this.opts.tuning();
578
+ const left = this.bot
579
+ ? this.exitFoes(
580
+ this.scan(this.bot, t, this.rangedReady(this.bot) ? RANGED_EXIT_RANGE : undefined), t,
581
+ ).length
582
+ : 0;
583
+ this.opts.emit(
584
+ `砍翻了${parts.join('、')}!${left > 0 ? `还有 ${left} 只在。` : '周围没别的了。'}`,
585
+ true, false,
586
+ );
587
+ }
588
+
589
+ /** 目标池:敌对、够近、不是打不过的;末影人只有惹过我们的才进(看它就是宣战) */
590
+ private scan(bot: Bot, t: CombatTuning, maxFlat = t.chaseMax + 4): Foe[] {
591
+ const out: Foe[] = [];
592
+ for (const key of Object.keys(bot.entities)) {
593
+ const e = bot.entities[key as unknown as number];
594
+ if (!e?.isValid || !e.position || e === bot.entity) continue;
595
+ if (this.deadIds.has(e.id)) continue;
596
+ const name = e.name ?? '';
597
+ const provoked = this.provoked.has(e.id);
598
+ if (name === 'piglin') {
599
+ if (!piglinIsHostile(bot, e, provoked)) continue;
600
+ } else if (!HOSTILE.has(name) && !provoked) continue;
601
+ if (name === 'enderman' && !provoked) continue;
602
+ const flat = Math.hypot(e.position.x - bot.entity.position.x, e.position.z - bot.entity.position.z);
603
+ if (flat > maxFlat) continue;
604
+ out.push({ id: e.id, name, ent: e as never, pos: e.position, reach: aabbReach(bot, e as never), flat });
605
+ }
606
+ return out;
607
+ }
608
+
609
+ /**
610
+ * 退出判据和剩余敌人数只统计常规半径内或已交手的目标。
611
+ * 可用远程武器扩大的扫描半径不扩大退出范围。
612
+ */
613
+ private exitFoes(foes: Foe[], t: CombatTuning): Foe[] {
614
+ const near = t.chaseMax + 4;
615
+ return foes.filter((f) => f.flat <= near || this.provoked.has(f.id));
616
+ }
617
+
618
+ /** 最近的 + 1.3 倍粘性。就这一条,威胁排序实测有害 */
619
+ private pick(foes: Foe[]): Foe | null {
620
+ const pool = foes.filter((f) => !NO_FIGHT.has(f.name));
621
+ if (pool.length === 0) return null;
622
+ const best = [...pool].sort((a, b) => a.reach - b.reach)[0];
623
+ const cur = pool.find((f) => f.id === this.targetId);
624
+ if (cur && best.id !== cur.id && !(cur.reach > best.reach * 1.3)) return cur;
625
+ this.targetId = best.id;
626
+ return best;
627
+ }
628
+
629
+ /** 三形态:贴身 ≥2 只 → 横扫;否则暴击;远程怪贴脸暴击(推远了它就开始射) */
630
+ private resolveMode(foes: Foe[], target: Foe): 'crit' | 'kb' | 'sweep' {
631
+ const bot = this.bot!;
632
+ // 水里 onGround 恒假,且无法保持横扫所需的站定状态,因此只用平 A。
633
+ if (bodyInWater(bot)) return 'kb';
634
+ const sword = (bot.heldItem?.name ?? '').endsWith('_sword');
635
+ const crowd = foes.filter((f) => f.reach <= 3.4).length;
636
+ if (sword && crowd >= SWEEP_CROWD) return 'sweep';
637
+ if (crowd >= KB_CROWD) return 'kb';
638
+ const others = foes.filter((f) => f.id !== target.id).map((f) => f.flat).sort((a, b) => a - b);
639
+ const space = others.length === 0 || others[0] > SOLO_SPACE;
640
+ if (space || RANGED.has(target.name)) return 'crit';
641
+ return 'kb';
642
+ }
643
+
644
+ private tick(): void {
645
+ if (this.state === 'idle') return;
646
+ const bot = this.bot;
647
+ if (!bot?.entity || bot !== this.opts.getBot()) { this.end('gone'); return; }
648
+ const now = Date.now();
649
+ // E6:环境自保找上门(岩浆/溺水),战斗静默让位,由环境反射自己汇报
650
+ if (this.opts.envBusy()) { this.end('env'); return; }
651
+ if (this.state === 'retreating') { this.retreatTick(bot, now); return; }
652
+
653
+ const t = this.opts.tuning();
654
+ const rangedReady = this.rangedReady(bot);
655
+ const foes = this.scan(
656
+ bot,
657
+ t,
658
+ this.desperate ? RETREAT_CLEAR_DIST : rangedReady ? RANGED_EXIT_RANGE : t.chaseMax + 4,
659
+ );
660
+ // E1:没怪了,滞后 3s 收工(防"炸完→退出→新怪→再进入"刷屏)。
661
+ // 「没怪了」按退出口径算(见 exitFoes):远处那些没交过手的不算数,
662
+ // 否则夜里 40 格内总有一只,这一局就永远收不了工。
663
+ const clearPool = this.desperate ? foes : this.exitFoes(foes, t);
664
+ if (clearPool.length === 0) {
665
+ if (this.emptySince === 0) this.emptySince = now;
666
+ this.opts.ranged?.abort();
667
+ this.rangedPendingOwner = null;
668
+ this.hybridWeapon = 'melee';
669
+ this.release(bot);
670
+ if (now - this.emptySince >= CLEAR_HOLD_MS) this.endClear();
671
+ return;
672
+ }
673
+ this.emptySince = 0;
674
+
675
+ // E3:硬时长。刷怪笼、夜晚开阔地的"索得到敌"可以永久为真,没有这道闸
676
+ // 就是脚本永久持有控制权、人格整场没有手
677
+ if (now >= this.deadlineAt) {
678
+ this.opts.emit(`打太久了,先收手——还有 ${foes.length} 只在附近,怎么办你来定。(生命 ${this.hp()}/20)`, true, false);
679
+ this.end('timeout');
680
+ return;
681
+ }
682
+ // E2:血线只在场上仍有活威胁时撤。死亡事件到实体表移除之间不能把已清场误判成逃跑。
683
+ // 转身还手中不看——血线的前提是"跑得掉",cornered 正是跑不掉才回的头。
684
+ if (!this.desperate && (bot.health ?? 20) < t.fleeHealth) {
685
+ this.opts.emit(`血被打到 ${this.hp()}/20 了,我先撤!`, true, true);
686
+ this.beginRetreat('flee');
687
+ return;
688
+ }
689
+
690
+ // 出手相位一旦起了就必须走完:台架第一轮 crit/sweep 整场只挥得出 2–4 刀,
691
+ // 就是因为别的分支每 tick 把起跳/站定重置掉
692
+ if (this.swingPhase !== 'idle') {
693
+ const held = foes.find((f) => f.id === this.phaseTargetId);
694
+ if (!held) { this.swingPhase = 'idle'; bot.setControlState('jump', false); }
695
+ else {
696
+ this.aimAt(bot, held);
697
+ this.tryOffense(held, this.resolveMode(foes, held), now);
698
+ return;
699
+ }
700
+ }
701
+
702
+ const target = this.pick(foes);
703
+ if (!target) {
704
+ // 场上只剩打不过的(E7):撤
705
+ this.opts.emit(`剩下的是${zhEntity(foes[0].name)},这个打不过,撤!`, true, false);
706
+ this.beginRetreat('no-fight');
707
+ return;
708
+ }
709
+
710
+ // 近战触及目标须同时满足距离和视线:原版服务端近战不检查墙体。
711
+ if (target.reach < this.lastReach - 0.3) this.lastProgressAt = now;
712
+ this.lastReach = target.reach;
713
+ if (target.reach <= REACH_MAX && this.losTo(bot, target)) {
714
+ this.lastProgressAt = now;
715
+ this.lastRealProgressAt = now;
716
+ }
717
+ if (now - Math.max(this.lastProgressAt, this.lastLandedAt) > UNREACHABLE_MS) {
718
+ this.opts.emit(`够不着${zhEntity(target.name)},不耗了。(生命 ${this.hp()}/20)`, true, false);
719
+ this.end('stuck');
720
+ return;
721
+ }
722
+ // 绝对时限只由实际可达或命中重置,reach 差分只刷新上面的软窗口。
723
+ if (now - Math.max(this.lastRealProgressAt, this.lastLandedAt) > UNREACHABLE_HARD_MS) {
724
+ this.opts.emit(
725
+ `缠了 ${Math.round(UNREACHABLE_HARD_MS / 1000)} 秒始终够不到${zhEntity(target.name)},不耗了。(生命 ${this.hp()}/20)`,
726
+ true, false,
727
+ );
728
+ this.end('stuck');
729
+ return;
730
+ }
731
+
732
+ // 挥刀达到门槛后仍未命中则收工;空挥不刷新命中时间。
733
+ if (this.swings >= NO_DAMAGE_SWINGS && now - this.lastLandedAt > NO_DAMAGE_MS) {
734
+ this.opts.emit(
735
+ `挥了 ${this.swings} 刀一下都没打到${zhEntity(target.name)},不耗了。(生命 ${this.hp()}/20)`,
736
+ true, false,
737
+ );
738
+ this.end('stuck');
739
+ return;
740
+ }
741
+
742
+ if (this.swings === 0 && this.arrows === 0 && now - this.startedAt > NO_SWING_MS) {
743
+ this.opts.emit(
744
+ `进场 ${Math.round(NO_SWING_MS / 1000)} 秒一下手都没出上(${zhEntity(target.name)}),不耗了。(生命 ${this.hp()}/20)`,
745
+ true, false,
746
+ );
747
+ this.end('stuck');
748
+ return;
749
+ }
750
+
751
+ const weapon = chooseHybridWeapon(this.hybridWeapon, target.reach, rangedReady);
752
+ if (weapon !== this.hybridWeapon) this.switchWeapon(bot, weapon);
753
+ if (this.hybridWeapon === 'ranged') {
754
+ this.driveRanged(bot, target, foes, now);
755
+ return;
756
+ }
757
+
758
+ this.aimAt(bot, target);
759
+
760
+ // 接近卡住 1.5s 交给寻路器,够得着立刻收回来自己打
761
+ if (!this.pathing && target.reach > 3.4 && now - this.lastProgressAt > STUCK_PATH_MS) {
762
+ this.pathing = true;
763
+ this.pathUntil = now + 4_000;
764
+ setOwnedGoal(bot, new goals.GoalFollow(target.ent as never, 2), 'combat', '接近目标', { dynamic: true, diag: this.opts.diag });
765
+ }
766
+ if (this.pathing) {
767
+ if (target.reach <= 3.2 || now > this.pathUntil) {
768
+ // 走到了才算进展;窗口空转到期不算,否则 E5 被这条无限续命
769
+ if (target.reach <= 3.2) {
770
+ this.lastProgressAt = now;
771
+ this.lastRealProgressAt = now;
772
+ }
773
+ this.pathing = false;
774
+ dropOwnedGoal(bot, 'combat', '够得着了,收回来自己打', this.opts.diag);
775
+ } else {
776
+ // 寻路期间视线归寻路器:两边每 tick 各拧一次,镜头里就是原地疯狂旋转
777
+ if (this.canSwing(now) && target.reach <= REACH_MAX) {
778
+ this.aimAt(bot, target);
779
+ this.tryOffense(target, this.resolveMode(foes, target), now);
780
+ }
781
+ this.statusTick(bot, foes, now);
782
+ return;
783
+ }
784
+ }
785
+
786
+ const mode = this.resolveMode(foes, target);
787
+ this.shieldLogic(bot, foes, now);
788
+ this.driveContext(bot, t, target, mode, now, foes);
789
+ this.tryOffense(target, mode, now);
790
+ this.statusTick(bot, foes, now);
791
+ }
792
+
793
+ /**
794
+ * 看向目标。够得着才低头瞄它身上——攻击射线要打在碰撞箱上,矮个子(蜘蛛、小僵尸)
795
+ * 本来就得低头。够不着就平视它的方向:那一刀反正打不出去,盯着脚下那一格只换来
796
+ * 镜头里一个对着地板转圈的人。
797
+ */
798
+ private aimAt(bot: Bot, f: Foe): void {
799
+ const y = f.reach <= REACH_MAX
800
+ ? f.pos.y + (f.ent.height ?? 1.8) * 0.75
801
+ : bot.entity.position.y + EYE;
802
+ void bot.lookAt(new Vec3(f.pos.x, y, f.pos.z), true).catch(() => undefined);
803
+ }
804
+
805
+ /** 这一刀看不看得见目标。原版服务端近战不查墙——隔墙发包真能打中,穿帮得我们自己拦 */
806
+ private losTo(bot: Bot, f: Foe): boolean {
807
+ return hasLosFrom(bot, bot.entity.position.offset(0, EYE, 0), f.ent as never);
808
+ }
809
+
810
+ /** 战况:每 6s 一条,不唤醒(接敌/击杀/撤退才唤醒) */
811
+ private statusTick(bot: Bot, foes: Foe[], now: number): void {
812
+ if (now - this.lastStatusAt < STATUS_EVERY_MS) return;
813
+ this.lastStatusAt = now;
814
+ const nearest = [...foes].sort((a, b) => a.reach - b.reach)[0];
815
+ this.opts.emit(
816
+ `还在打:${foes.length} 只在附近(最近的是${zhEntity(nearest.name)}),` +
817
+ `刀 ${this.swings} 次命中 ${this.meleeLanded()} 次,箭 ${this.arrows} 支命中 ${this.rangedLanded} 次,` +
818
+ `生命 ${this.hp()}/20。`,
819
+ false, false,
820
+ );
821
+ }
822
+
823
+ private rangedReady(bot: Bot): boolean {
824
+ return this.opts.ranged?.ready(bot) ?? false;
825
+ }
826
+
827
+ private switchWeapon(bot: Bot, next: HybridWeapon): void {
828
+ this.hybridWeapon = next;
829
+ this.swingPhase = 'idle';
830
+ bot.setControlState('jump', false);
831
+ if (next === 'ranged') {
832
+ if (this.pathing) {
833
+ this.pathing = false;
834
+ dropOwnedGoal(bot, 'combat', '改用远程,寻路器让位', this.opts.diag);
835
+ }
836
+ this.lowerShield(bot);
837
+ return;
838
+ }
839
+ this.opts.ranged?.abort();
840
+ this.rangedPendingOwner = null;
841
+ const weapon = bestWeapon(bot);
842
+ if (weapon) void bot.equip(weapon, 'hand').catch(() => undefined);
843
+ }
844
+
845
+ private driveRanged(bot: Bot, target: Foe, foes: Foe[], now: number): void {
846
+ this.lowerShield(bot);
847
+ const dx = target.pos.x - bot.entity.position.x;
848
+ const dz = target.pos.z - bot.entity.position.z;
849
+ if (target.reach > 14) {
850
+ this.driveRangedDirection(bot, [{ x: dx, z: dz }], true);
851
+ } else if (target.reach < 8) {
852
+ this.driveRangedDirection(bot, [{ x: -dx, z: -dz }], true);
853
+ } else if (inKiteRange(target.reach)) {
854
+ this.driveRangedDirection(bot, [
855
+ { x: -dz, z: dx },
856
+ { x: dz, z: -dx },
857
+ ], true);
858
+ } else {
859
+ this.stopMovement(bot);
860
+ }
861
+ this.tryRanged(target, now);
862
+ this.statusTick(bot, foes, now);
863
+ }
864
+
865
+ /** 弓态不靠寻路器接管视线;只在实际移动方向有干燥落脚格时写水平键。 */
866
+ private driveRangedDirection(
867
+ bot: Bot,
868
+ candidates: Array<{ x: number; z: number }>,
869
+ sprint: boolean,
870
+ ): void {
871
+ const direction = candidates.find((d) => this.rangedDirectionSafe(bot, d.x, d.z));
872
+ if (!direction) {
873
+ this.stopMovement(bot);
874
+ if (bodyInWater(bot)) bot.setControlState('jump', true);
875
+ return;
876
+ }
877
+ this.pressToward(bot, direction.x, direction.z, sprint);
878
+ this.autoJump(bot, direction.x, direction.z);
879
+ }
880
+
881
+ private rangedDirectionSafe(bot: Bot, dx: number, dz: number): boolean {
882
+ const len = Math.hypot(dx, dz);
883
+ if (len < 1e-6) return false;
884
+ const me = bot.entity.position;
885
+ const base = me.floored();
886
+ const x = Math.floor(me.x + (dx / len) * 1.3);
887
+ const z = Math.floor(me.z + (dz / len) * 1.3);
888
+ return this.rangedStandable(bot, x, base.y, z) ||
889
+ this.rangedStandable(bot, x, base.y + 1, z) ||
890
+ this.rangedStandable(bot, x, base.y - 1, z);
891
+ }
892
+
893
+ private rangedStandable(bot: Bot, x: number, y: number, z: number): boolean {
894
+ const below = bot.blockAt(new Vec3(x, y - 1, z));
895
+ const feet = bot.blockAt(new Vec3(x, y, z));
896
+ const head = bot.blockAt(new Vec3(x, y + 1, z));
897
+ if (!below || !feet || !head) return false;
898
+ const liquid = (name: string): boolean => name === 'water' || name === 'lava' || name === 'bubble_column';
899
+ return below.boundingBox === 'block' && feet.boundingBox === 'empty' && head.boundingBox === 'empty' &&
900
+ !liquid(feet.name) && !liquid(head.name);
901
+ }
902
+
903
+ private tryRanged(target: Foe, now: number): void {
904
+ const ranged = this.opts.ranged;
905
+ const owner = this.rangedOwner;
906
+ if (!ranged || !owner || this.rangedPendingOwner || ranged.active() || now < this.nextRangedAt) return;
907
+ this.nextRangedAt = now + 250;
908
+ this.rangedPendingOwner = owner;
909
+ void ranged.shoot({
910
+ id: target.id,
911
+ position: target.pos,
912
+ height: target.ent.height,
913
+ width: target.ent.width,
914
+ }, owner).catch((error: unknown) => {
915
+ this.opts.log.warn(`远程出手失败:${error instanceof Error ? error.message : String(error)}`);
916
+ }).finally(() => {
917
+ if (this.rangedPendingOwner === owner) this.rangedPendingOwner = null;
918
+ });
919
+ }
920
+
921
+ private meleeLanded(): number {
922
+ return Math.max(0, this.landed - this.rangedLanded);
923
+ }
924
+
925
+ // —— v7 移动:上下文图 ————————————————————————————————————————
926
+
927
+ private driveContext(bot: Bot, t: CombatTuning, target: Foe, mode: 'crit' | 'kb' | 'sweep', now: number, foes: Foe[]): void {
928
+ // 横扫要求出手那一 tick 几乎没动:站定期间任何移动都不许写
929
+ if (this.swingPhase === 'settle') {
930
+ for (const k of ['forward', 'back', 'left', 'right', 'sprint'] as const) bot.setControlState(k, false);
931
+ return;
932
+ }
933
+ const I = this.ctxI;
934
+ const D = this.ctxD;
935
+ I.fill(0);
936
+ D.fill(0);
937
+ const me = bot.entity.position;
938
+
939
+ // interest:亚格间距。贴着攻击距离上沿做比例控制,把目标的接近速度补进误差——
940
+ // 它以 v 逼近,我就以 v 后退(spacing,单独值 1.8×)
941
+ const tx = target.pos.x - me.x, tz = target.pos.z - me.z;
942
+ const tlen = Math.hypot(tx, tz) || 1;
943
+ const ideal = RANGED.has(target.name) ? RANGED_STOP : t.space;
944
+ const tv = (target.ent as unknown as { velocity?: { x: number; z: number } }).velocity;
945
+ const closing = tv ? -((tv.x * tx + tv.z * tz) / tlen) * 20 : 0;
946
+ const err = (target.reach - closing * SPACE_PREDICT_S) - ideal;
947
+ if (err > 0.10) write(I, tx, tz, Math.min(1.0, 0.45 + err), 1);
948
+ else if (err < -0.10) write(I, -tx, -tz, Math.min(1.0, 0.45 - err), 1);
949
+
950
+ // interest:公转。两侧切向都写,怪少的那侧写得更强——绕着走把它们叠成一个扇区,
951
+ // 同时能碰到你的嘴变少,横扫一刀吃到的反而多
952
+ const lx = -tz / tlen, lz = tx / tlen;
953
+ let leftMass = 0, rightMass = 0;
954
+ for (const f of foes) {
955
+ if (f.flat > 8) continue;
956
+ const lat = (f.pos.x - me.x) * lx + (f.pos.z - me.z) * lz;
957
+ const w = 1 / (1 + f.flat);
958
+ if (lat > 0) leftMass += w;
959
+ else rightMass += w;
960
+ }
961
+ write(I, lx, lz, leftMass <= rightMass ? 0.75 : 0.4, 2);
962
+ write(I, -lx, -lz, rightMass < leftMass ? 0.75 : 0.4, 2);
963
+
964
+ // interest:掩体(退进更窄的落脚格)。只是写图的一个行为,不独占方向盘
965
+ this.pickCoverCell(bot, foes, now);
966
+ const c = this.coverCell;
967
+ if (c && !this.coverArrived(bot)) {
968
+ write(I, c.x + 0.5 - me.x, c.z + 0.5 - me.z, 0.65, 2);
969
+ }
970
+
971
+ // danger:怪群
972
+ for (const f of foes) {
973
+ if (f.flat > 10) continue;
974
+ write(D, f.pos.x - me.x, f.pos.z - me.z, 1.6 / (1 + f.flat * 0.55), 2);
975
+ }
976
+ // danger:苦力怕圈(往「走过去仍在圈内且它看得见我」的方向写重危险;
977
+ // 被方块挡住视线的方向不写——「绕到柱子后面」是图的自然结果,不是一个模式)
978
+ this.writeCreeperDanger(bot, D, foes, now);
979
+ // danger:地形硬 masking
980
+ this.writeTerrainDanger(bot, D, now);
981
+
982
+ blur(I);
983
+ blur(D);
984
+ blend(this.ctxPrevI, I, 0.45);
985
+ blend(this.ctxPrevD, D, 0.55);
986
+ this.ctxPrevI.set(I);
987
+ this.ctxPrevD.set(D);
988
+
989
+ const choice = decide(I, D, 0.25);
990
+ if (!choice) { this.release(bot); return; }
991
+ this.pressToward(bot, choice.x, choice.z, choice.strength > 0.7 && mode === 'kb');
992
+ this.autoJump(bot, choice.x, choice.z);
993
+ }
994
+
995
+ /** 每 150ms 重算:16 条射线不该每 tick 都打 */
996
+ private writeCreeperDanger(bot: Bot, D: Map16, foes: Foe[], now: number): void {
997
+ if (now - this.ctxCreeperAt > 150) {
998
+ this.ctxCreeperAt = now;
999
+ this.ctxCreeper.fill(0);
1000
+ const me = bot.entity.position;
1001
+ for (const f of foes) {
1002
+ if (f.name !== 'creeper' || f.flat > 8) continue;
1003
+ for (let i = 0; i < 16; i++) {
1004
+ const d = slotDir(i);
1005
+ const px = me.x + d.x * 2.2, pz = me.z + d.z * 2.2;
1006
+ if (Math.hypot(px - f.pos.x, pz - f.pos.z) > CREEPER_SAFE) continue;
1007
+ const eye = new Vec3(px, me.y + EYE, pz);
1008
+ if (!hasLosFrom(bot, eye, f.ent as never)) continue;
1009
+ writeSlot(this.ctxCreeper, i, 3.0);
1010
+ }
1011
+ }
1012
+ }
1013
+ for (let i = 0; i < 16; i++) writeSlot(D, i, this.ctxCreeper[i]);
1014
+ }
1015
+
1016
+ /** 每 200ms 重算:走不过去的方向直接 HARD,永不入选 */
1017
+ private writeTerrainDanger(bot: Bot, D: Map16, now: number): void {
1018
+ if (now - this.ctxTerrainAt > 200) {
1019
+ this.ctxTerrainAt = now;
1020
+ this.ctxTerrain.fill(0);
1021
+ const me = bot.entity.position;
1022
+ const base = me.floored();
1023
+ for (let i = 0; i < 16; i++) {
1024
+ const d = slotDir(i);
1025
+ const x = Math.floor(me.x + d.x * 1.3);
1026
+ const z = Math.floor(me.z + d.z * 1.3);
1027
+ if (this.standable(bot, x, base.y, z) || this.standable(bot, x, base.y + 1, z) || this.standable(bot, x, base.y - 1, z)) continue;
1028
+ writeSlot(this.ctxTerrain, i, HARD);
1029
+ }
1030
+ }
1031
+ for (let i = 0; i < 16; i++) writeSlot(D, i, this.ctxTerrain[i]);
1032
+ }
1033
+
1034
+ private standable(bot: Bot, x: number, y: number, z: number): boolean {
1035
+ const below = bot.blockAt(new Vec3(x, y - 1, z));
1036
+ const feet = bot.blockAt(new Vec3(x, y, z));
1037
+ const head = bot.blockAt(new Vec3(x, y + 1, z));
1038
+ if (!below || !feet || !head) return false;
1039
+ // 岩浆的 boundingBox 是 empty;危险方块须额外按与 world.ts 共用的名称集合排除。
1040
+ if (BURNING_BLOCKS.has(feet.name) || BURNING_BLOCKS.has(head.name)) return false;
1041
+ if (BURNING_BLOCKS.has(below.name) || SCORCHING_FLOOR.has(below.name)) return false;
1042
+ return below.boundingBox === 'block' && feet.boundingBox === 'empty' && head.boundingBox === 'empty';
1043
+ }
1044
+
1045
+ /** 敞开度:八个水平方向里有几个是通的 */
1046
+ private openness(bot: Bot, x: number, y: number, z: number): number {
1047
+ let n = 0;
1048
+ for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]] as const) {
1049
+ const feet = bot.blockAt(new Vec3(x + dx, y, z + dz));
1050
+ const head = bot.blockAt(new Vec3(x + dx, y + 1, z + dz));
1051
+ if (feet?.boundingBox === 'empty' && head?.boundingBox === 'empty') n++;
1052
+ }
1053
+ return n;
1054
+ }
1055
+
1056
+ /** 掩体:背离怪群那一侧找更窄的落脚格。任何穿过怪堆的移动都输,所以只往背面找 */
1057
+ private pickCoverCell(bot: Bot, foes: Foe[], now: number): void {
1058
+ if (now - this.coverAt < 900) return;
1059
+ this.coverAt = now;
1060
+ if (foes.length === 0) { this.coverCell = null; return; }
1061
+ const me = bot.entity.position;
1062
+ const base = me.floored();
1063
+ let cx = 0, cz = 0;
1064
+ for (const f of foes) { cx += f.pos.x; cz += f.pos.z; }
1065
+ cx /= foes.length;
1066
+ cz /= foes.length;
1067
+ const ax = me.x - cx, az = me.z - cz;
1068
+ const alen = Math.hypot(ax, az) || 1;
1069
+ const hereOpen = this.openness(bot, base.x, base.y, base.z);
1070
+ let best: { x: number; y: number; z: number } | null = null;
1071
+ let bestScore = -Infinity;
1072
+ for (let dx = -5; dx <= 5; dx++) {
1073
+ for (let dz = -5; dz <= 5; dz++) {
1074
+ const d = Math.hypot(dx, dz);
1075
+ if (d < 1 || d > 5) continue;
1076
+ if ((dx * ax + dz * az) / (d * alen) < 0.1) continue;
1077
+ for (let dy = 1; dy >= -1; dy--) {
1078
+ const x = base.x + dx, y = base.y + dy, z = base.z + dz;
1079
+ if (!this.standable(bot, x, y, z)) continue;
1080
+ const open = this.openness(bot, x, y, z);
1081
+ const score = -open * 2 - d * 0.6;
1082
+ if (score > bestScore) { bestScore = score; best = { x, y, z }; }
1083
+ break;
1084
+ }
1085
+ }
1086
+ }
1087
+ if (!best) { this.coverCell = null; return; }
1088
+ if (hereOpen - this.openness(bot, best.x, best.y, best.z) < 2) { this.coverCell = null; return; }
1089
+ this.coverCell = best;
1090
+ }
1091
+
1092
+ private coverArrived(bot: Bot): boolean {
1093
+ const c = this.coverCell;
1094
+ if (!c) return true;
1095
+ const p = bot.entity.position;
1096
+ return Math.hypot(p.x - (c.x + 0.5), p.z - (c.z + 0.5)) < 1.0 && Math.abs(p.y - c.y) < 1.2;
1097
+ }
1098
+
1099
+ /**
1100
+ * 世界方向 → 方向键。局部坐标系是台架量出来钉死的(probe-frame.ts),别照公式推:
1101
+ * 符号推反了不报错,只会让人朝反方向走,在战斗数据里表现成「这条战术没用」。
1102
+ */
1103
+ private pressToward(bot: Bot, dx: number, dz: number, sprint = false): void {
1104
+ const len = Math.hypot(dx, dz);
1105
+ if (len < 1e-6) return;
1106
+ const ux = dx / len, uz = dz / len;
1107
+ const y = bot.entity.yaw;
1108
+ const f = ux * -Math.sin(y) + uz * -Math.cos(y);
1109
+ const l = ux * -Math.cos(y) + uz * Math.sin(y);
1110
+ bot.setControlState('forward', f > 0.35);
1111
+ bot.setControlState('back', f < -0.35);
1112
+ bot.setControlState('left', l > 0.35);
1113
+ bot.setControlState('right', l < -0.35);
1114
+ const wantSprint = sprint && f > 0.35 && Date.now() >= this.wtapUntil;
1115
+ if (wantSprint && this.sprintSince === 0) this.sprintSince = Date.now();
1116
+ if (!wantSprint) this.sprintSince = 0;
1117
+ bot.setControlState('sprint', wantSprint);
1118
+ }
1119
+
1120
+ private stopMovement(bot: Bot): void {
1121
+ for (const key of ['forward', 'back', 'left', 'right', 'sprint', 'jump'] as const) {
1122
+ bot.setControlState(key, false);
1123
+ }
1124
+ }
1125
+
1126
+ /** 前面是一格高的坎就跳上去;水里按住跳=向上游(不按就往下沉,沉到头进水战斗就被防溺水抢走) */
1127
+ private autoJump(bot: Bot, dx?: number, dz?: number): void {
1128
+ if (this.swingPhase === 'hop') return;
1129
+ if (bodyInWater(bot)) { bot.setControlState('jump', true); return; }
1130
+ const len = dx === undefined || dz === undefined ? 0 : Math.hypot(dx, dz);
1131
+ const ax = len > 1e-6 ? dx! / len : -Math.sin(bot.entity.yaw);
1132
+ const az = len > 1e-6 ? dz! / len : -Math.cos(bot.entity.yaw);
1133
+ const ahead = bot.entity.position.offset(ax * 0.8, 0, az * 0.8);
1134
+ const feet = bot.blockAt(ahead);
1135
+ const head = bot.blockAt(ahead.offset(0, 1, 0));
1136
+ const blocked = feet?.boundingBox === 'block' && head?.boundingBox !== 'block';
1137
+ bot.setControlState('jump', Boolean(blocked) && bot.entity.onGround);
1138
+ }
1139
+
1140
+ // —— v7 盾与出手 ————————————————————————————————————————————
1141
+
1142
+ private raiseShield(bot: Bot): void {
1143
+ const off = (bot.inventory.slots[45] as { name?: string } | null)?.name;
1144
+ if (off !== 'shield') return;
1145
+ try { bot.activateItem(true); this.shieldUp = true; } catch { /* noop */ }
1146
+ }
1147
+
1148
+ private lowerShield(bot: Bot): void {
1149
+ if (!this.shieldUp) return;
1150
+ try { bot.deactivateItem(); } catch { /* noop */ }
1151
+ this.shieldUp = false;
1152
+ }
1153
+
1154
+ /** 远程怪在射程内且近处没贴脸的就举着;出手前放下(抬起要 5 tick 才生效) */
1155
+ private shieldLogic(bot: Bot, foes: Foe[], now: number): void {
1156
+ const ranged = foes.some((f) => RANGED.has(f.name) && f.flat < 18);
1157
+ const closeMelee = foes.some((f) => !RANGED.has(f.name) && f.reach < 3.2);
1158
+ const want = ranged && !closeMelee && now >= this.shieldHoldUntil;
1159
+ if (want && !this.shieldUp) this.raiseShield(bot);
1160
+ if (!want && this.shieldUp) this.lowerShield(bot);
1161
+ }
1162
+
1163
+ private canSwing(now: number): boolean {
1164
+ return this.bot !== null && now - this.lastSwingAt >= meleeCooldownMs(this.bot);
1165
+ }
1166
+
1167
+ /**
1168
+ * 出手。前摇排进冷却窗口(prep,单独值 1.6×):横扫站定 100ms、跳劈起跳约 300ms,
1169
+ * 不是冷却好了再准备——提前 lead 毫秒进入前摇,冷却一满正好在暴击/横扫的姿态上。
1170
+ */
1171
+ private tryOffense(target: Foe, mode: 'crit' | 'kb' | 'sweep', now: number): void {
1172
+ const bot = this.bot!;
1173
+ const inReach = target.reach <= REACH_MAX;
1174
+ const cd = meleeCooldownMs(bot);
1175
+ const left = cd - (now - this.lastSwingAt);
1176
+ const ready = left <= 0;
1177
+
1178
+ if (this.swingPhase === 'hop') {
1179
+ const vy = bot.entity.velocity?.y ?? 0;
1180
+ if (vy < -0.05 && ready) { bot.setControlState('jump', false); this.swing(bot, target, now); return; }
1181
+ if (now > this.phaseUntil) {
1182
+ bot.setControlState('jump', false);
1183
+ if (ready) this.swing(bot, target, now);
1184
+ else this.swingPhase = 'idle';
1185
+ }
1186
+ return;
1187
+ }
1188
+ if (this.swingPhase === 'settle') {
1189
+ if (now >= this.phaseUntil && ready) { this.swing(bot, target, now); return; }
1190
+ if (now > this.phaseUntil + 400) this.swingPhase = 'idle';
1191
+ return;
1192
+ }
1193
+ if (!inReach) return;
1194
+ // 隔着方块不出手:打得中也不打(镜头里像开挂),E5 那边同样不算「够得着」
1195
+ if (!this.losTo(bot, target)) return;
1196
+
1197
+ const lead = mode === 'crit' ? 300 : 100;
1198
+ if (left > lead) return;
1199
+
1200
+ if (mode === 'crit') {
1201
+ if (bot.entity.onGround) {
1202
+ bot.setControlState('sprint', false);
1203
+ bot.setControlState('jump', true);
1204
+ this.swingPhase = 'hop';
1205
+ this.phaseTargetId = target.id;
1206
+ this.phaseUntil = now + 450 + Math.max(0, lead);
1207
+ }
1208
+ return;
1209
+ }
1210
+ if (mode === 'sweep') {
1211
+ this.swingPhase = 'settle';
1212
+ this.phaseTargetId = target.id;
1213
+ this.phaseUntil = now + 100;
1214
+ return;
1215
+ }
1216
+ if (!ready) return;
1217
+ // kb:命中瞬间要在疾跑;控制状态下一 tick 才发包,先按住攒一 tick 再出手。
1218
+ // 攒不出疾跑就别一直攒——冷却空转半秒,软伤害也比不出手强
1219
+ // 水里疾跑攒不出来:别为它空转冷却,到点就出手
1220
+ const overdue = now - this.lastSwingAt > cd + 500 || bodyInWater(bot);
1221
+ if (!overdue && (!bot.controlState.sprint || now - this.sprintSince < 60)) {
1222
+ bot.setControlState('forward', true);
1223
+ bot.setControlState('sprint', true);
1224
+ if (this.sprintSince === 0) this.sprintSince = now;
1225
+ return;
1226
+ }
1227
+ this.swing(bot, target, now);
1228
+ this.wtapUntil = now + 120;
1229
+ }
1230
+
1231
+ private swing(bot: Bot, target: Foe, now: number): void {
1232
+ this.swingPhase = 'idle';
1233
+ if (this.shieldUp) { this.lowerShield(bot); this.shieldHoldUntil = now + 250; }
1234
+ try { bot.attack(target.ent as never); } catch { /* 实体刚没 */ }
1235
+ this.lastSwingAt = now;
1236
+ this.swings += 1;
1237
+ this.lastSwingTargetId = target.id;
1238
+ this.struck.add(target.id);
1239
+ }
1240
+
1241
+ // —— 撤退与收尾 ————————————————————————————————————————————
1242
+
1243
+ private beginRetreat(why: 'flee' | 'mc_stop' | 'no-fight'): void {
1244
+ this.cancelRanged();
1245
+ const bot = this.bot;
1246
+ if (!bot?.entity) { this.end('gone'); return; }
1247
+ // 没打起来就直接撤(E7 / 血线挡下):E4 滑窗记的是这一场,起点得是现在,
1248
+ // 否则 end() 会把上一场的开始时刻算成本场时长
1249
+ if (this.state === 'idle') {
1250
+ this.startedAt = Date.now();
1251
+ this.deadIds.clear();
1252
+ }
1253
+ this.state = 'retreating';
1254
+ this.retreatWhy = why;
1255
+ const now = Date.now();
1256
+ this.retreatStartedAt = now;
1257
+ this.swingPhase = 'idle';
1258
+ if (this.pathing) { this.pathing = false; dropOwnedGoal(bot, 'combat', '转撤退', this.opts.diag); }
1259
+ this.lowerShield(bot);
1260
+ const foes = this.scan(bot, this.opts.tuning(), RETREAT_CLEAR_DIST);
1261
+ let cx = 0, cz = 0;
1262
+ for (const f of foes) { cx += f.pos.x; cz += f.pos.z; }
1263
+ this.retreatFrom = foes.length > 0
1264
+ ? { x: cx / foes.length, z: cz / foes.length }
1265
+ : { x: bot.entity.position.x + 1, z: bot.entity.position.z };
1266
+ this.retreatHits = 0;
1267
+ // 触发这场撤退的那次伤害也属于安全时窗;否则第一拍实体表短暂丢失就会立刻收工。
1268
+ this.retreatLastHurtAt = now;
1269
+ this.retreatSafeSince = 0;
1270
+ this.retreatProgressAt = now;
1271
+ this.retreatProgressPos = {
1272
+ x: bot.entity.position.x, y: bot.entity.position.y, z: bot.entity.position.z,
1273
+ };
1274
+ this.retreatEscalated = false;
1275
+ this.retreatBarehandNoted = false;
1276
+ // 在水里"拉开 N 格"是永远追不上的目标(溺尸游得比人快),赢法只有出水:目标改成最近的岸
1277
+ this.retreatBank = bodyInWater(bot) ? findBankCell(bot, this.retreatFrom, 16) : null;
1278
+ if (this.retreatBank) {
1279
+ const b = this.retreatBank;
1280
+ this.opts.diag?.write({
1281
+ lane: 'combat', event: 'retreat-to-bank',
1282
+ msg: `在水里撤退:朝 (${b.x}, ${b.y}, ${b.z}) 的登岸点游`,
1283
+ data: { bank: b },
1284
+ });
1285
+ }
1286
+ }
1287
+
1288
+ /** 撤退:背对怪群疾跑;距离安全并守住无伤时窗才收,到时仍危险就升级策略 */
1289
+ private retreatTick(bot: Bot, now: number): void {
1290
+ const from = this.retreatFrom!;
1291
+ const p = bot.entity.position;
1292
+ const foes = this.scan(bot, this.opts.tuning(), RETREAT_CLEAR_DIST);
1293
+ const nearest = foes.reduce((m, f) => Math.min(m, f.flat), 99);
1294
+ const last = this.retreatProgressPos;
1295
+ if (!last || Math.hypot(p.x - last.x, p.y - last.y, p.z - last.z) >= 1) {
1296
+ this.retreatProgressAt = now;
1297
+ this.retreatProgressPos = { x: p.x, y: p.y, z: p.z };
1298
+ }
1299
+ const stalled = now - this.retreatProgressAt >= RETREAT_STALL_MS;
1300
+ const recentlyHurt = now - this.retreatLastHurtAt < RETREAT_SAFE_HOLD_MS;
1301
+ const inWater = bodyInWater(bot);
1302
+ if (this.retreatBank && !inWater) this.retreatBank = null; // 上岸了,剩下按老样子拉开
1303
+ // 游向岸比跑步慢:有登岸目标时放宽时限,别在半程收工又被追回水里
1304
+ const maxMs = this.retreatBank ? RETREAT_MAX_MS * 2 : RETREAT_MAX_MS;
1305
+ const timedOut = now - this.retreatStartedAt >= maxMs;
1306
+
1307
+ // 跑不掉时近战按贴身判据还手;远程目标须持续命中且撤退无进展。
1308
+ // RETREAT_MAX_MS 到期只升级撤退策略,不单独触发转身还手。
1309
+ const fightable = foes.filter((f) => !NO_FIGHT.has(f.name)).sort((a, b) => a.reach - b.reach);
1310
+ if (this.retreatWhy === 'flee' && fightable.length > 0) {
1311
+ const threat = fightable[0];
1312
+ const closeCornered = this.retreatHits >= 2 && now - this.retreatStartedAt >= 1_500 && threat.reach <= 3.2;
1313
+ const rangedCornered = this.retreatHits >= 2 && recentlyHurt && stalled;
1314
+ if (closeCornered || rangedCornered) {
1315
+ if (this.turnAndFight(bot, threat, now, closeCornered ? 'close' : 'stalled')) return;
1316
+ }
1317
+ }
1318
+
1319
+ // 撤退证伪的另一面:跑不动、也没人打。这不是把撤退记成成功 —— 是它已经证明
1320
+ // 自己什么都挡不住,身体该还给任务;再挨打 `onHurtBy` 照常重开这一场。
1321
+ if (timedOut && stalled && now - this.retreatLastHurtAt >= RETREAT_IDLE_MS) {
1322
+ this.opts.emit(
1323
+ `跑不动,也没人打我——${nearest < 99 ? `最近的还在 ${Math.round(nearest)} 格外` : '附近也没怪了'},先把手还回去。(生命 ${this.hp()}/20)`,
1324
+ true, false,
1325
+ );
1326
+ this.opts.diag?.write({
1327
+ lane: 'combat', event: 'retreat-idle',
1328
+ msg: `撤退 ${Math.round((now - this.retreatStartedAt) / 1000)}s 无位移且 ` +
1329
+ `${Math.round((now - this.retreatLastHurtAt) / 1000)}s 无伤:收工,最近威胁 ${Math.round(nearest * 10) / 10} 格`,
1330
+ data: { nearest, hits: this.retreatHits, why: this.retreatWhy },
1331
+ });
1332
+ this.end('stuck');
1333
+ return;
1334
+ }
1335
+
1336
+ // 计时只触发策略升级。打不过的目标和明确 mc_stop 不强行改成反击,但也不能
1337
+ // 把威胁还在附近的状态写成成功;继续持有撤退身体并只记一次升级事实。
1338
+ if (timedOut && nearest <= RETREAT_CLEAR_DIST && !this.retreatEscalated) {
1339
+ this.retreatEscalated = true;
1340
+ this.opts.diag?.write({
1341
+ lane: 'combat', event: 'retreat-extended',
1342
+ msg: `撤退到时仍不安全:最近威胁 ${Math.round(nearest * 10) / 10} 格,继续撤`,
1343
+ data: { nearest, hits: this.retreatHits, stalled, why: this.retreatWhy },
1344
+ });
1345
+ }
1346
+
1347
+ const distanceSafe = nearest > RETREAT_CLEAR_DIST && !inWater;
1348
+ if (distanceSafe) {
1349
+ if (this.retreatSafeSince === 0) this.retreatSafeSince = now;
1350
+ } else {
1351
+ this.retreatSafeSince = 0;
1352
+ }
1353
+ if (
1354
+ distanceSafe &&
1355
+ now - this.retreatSafeSince >= RETREAT_SAFE_HOLD_MS &&
1356
+ now - this.retreatLastHurtAt >= RETREAT_SAFE_HOLD_MS
1357
+ ) {
1358
+ const doneWhy = this.retreatWhy;
1359
+ this.opts.emit(
1360
+ doneWhy === 'flee'
1361
+ ? `跑开了,喘口气。生命 ${this.hp()}/20。`
1362
+ : `撤出来了。生命 ${this.hp()}/20。`,
1363
+ false, false,
1364
+ );
1365
+ this.end(doneWhy === 'mc_stop' ? 'mc_stop' : 'flee');
1366
+ return;
1367
+ }
1368
+ const to = this.retreatBank;
1369
+ const dx = to ? to.x + 0.5 - p.x : p.x - from.x;
1370
+ const dz = to ? to.z + 0.5 - p.z : p.z - from.z;
1371
+ void bot.lookAt(new Vec3(p.x + dx * 4, p.y + EYE, p.z + dz * 4), true).catch(() => undefined);
1372
+ this.pressToward(bot, dx, dz, true);
1373
+ this.autoJump(bot, dx, dz);
1374
+ }
1375
+
1376
+ /**
1377
+ * 撤退失败后转身还手,重新选择武器;本场不再检查 E2 和普通入场血线,
1378
+ * E1/E3/E5/E8 仍生效。无近战武器、无可用远程且低于 fleeHealth + ENGAGE_MARGIN
1379
+ * 时返回 false 并继续撤退。
1380
+ */
1381
+ private turnAndFight(bot: Bot, glued: Foe, now: number, cause: 'close' | 'stalled'): boolean {
1382
+ const line = this.opts.tuning().fleeHealth + ENGAGE_MARGIN;
1383
+ if (!bestWeapon(bot) && !this.rangedReady(bot) && (bot.health ?? 20) < line) {
1384
+ if (!this.retreatBarehandNoted) {
1385
+ this.retreatBarehandNoted = true;
1386
+ this.opts.diag?.write({
1387
+ lane: 'combat', event: 'cornered-barehand',
1388
+ msg: `撤退受阻(${cause})但空手、生命 ${this.hp()}/20 低于 ${line}:不转身还手,继续撤`,
1389
+ data: { cause, hits: this.retreatHits, foe: glued.name, distance: glued.flat, health: bot.health, line },
1390
+ });
1391
+ }
1392
+ return false;
1393
+ }
1394
+ this.desperate = true;
1395
+ this.state = 'fighting';
1396
+ this.retreatBank = null;
1397
+ this.deadlineAt = now + this.opts.tuning().maxSec * 1000;
1398
+ this.lastProgressAt = now;
1399
+ this.lastRealProgressAt = now;
1400
+ this.lastLandedAt = now;
1401
+ this.lastStatusAt = now;
1402
+ this.emptySince = 0;
1403
+ this.swingPhase = 'idle';
1404
+ this.targetId = glued.id;
1405
+ this.hybridWeapon = 'melee';
1406
+ this.rangedOwner = {};
1407
+ this.rangedPendingOwner = null;
1408
+ this.nextRangedAt = now;
1409
+ const weapon = bestWeapon(bot);
1410
+ if (weapon) void bot.equip(weapon, 'hand').catch(() => undefined);
1411
+ const pressure = glued.reach <= 3.2 ? '还咬着我' : '还在远处压着我';
1412
+ this.opts.emit(`跑不掉,${zhEntity(glued.name)}${pressure}——回头打!(生命 ${this.hp()}/20)`, true, true);
1413
+ this.opts.diag?.write({
1414
+ lane: 'combat', event: 'cornered',
1415
+ msg: `撤退失败(${cause}):挨了 ${this.retreatHits} 下,${glued.name}在 ${Math.round(glued.flat * 10) / 10} 格:转身还手,生命 ${this.hp()}/20`,
1416
+ data: { cause, hits: this.retreatHits, foe: glued.name, distance: glued.flat, health: bot.health },
1417
+ });
1418
+ return true;
1419
+ }
1420
+
1421
+ /**
1422
+ * 环境自保夺权(岩浆/溺水):立刻放开身体,静默收工——由环境反射自己汇报,
1423
+ * 战斗这边不抢话。优先级 ENV > FIGHT > TASK 里的那一道。
1424
+ */
1425
+ yieldToEnv(): void {
1426
+ if (this.state !== 'idle') this.end('env');
1427
+ }
1428
+
1429
+ /**
1430
+ * `queue:"now"` 夺手:她说的「现在」就是现在,战斗也放下。
1431
+ *
1432
+ * 与 `mc_stop` 的收手分得开:那一条要撤退,而撤退期间身体仍归战斗,新任务照样
1433
+ * 跑不起来;这一条当场把身体交还,任务立刻开跑。交还后留一小段不自动进场的宽限
1434
+ * —— 250ms 巡检会在下一拍把刚开跑的任务重新挂起,那就等于什么都没改。挨打仍走
1435
+ * `onHurtBy`(宽限期内它退回反射的降级行为),环境反射照旧在两者之上。
1436
+ *
1437
+ * 返回刚才在做什么(受理回执照实说一句);本来就空闲返回 null。
1438
+ */
1439
+ standDown(): string | null {
1440
+ if (this.state === 'idle') return null;
1441
+ // queue:"now" 只更新普通任务意图,不清除受击和无进展记录。
1442
+ // 低血、绝境战斗或距上次受击不足 RETREAT_IDLE_MS 的撤退仍持有身体,急件须等待。
1443
+ const now = Date.now();
1444
+ const retreatStillSaving = this.state === 'retreating' && now - this.retreatLastHurtAt < RETREAT_IDLE_MS;
1445
+ if (retreatStillSaving || this.desperate || (this.bot?.health ?? 0) < this.opts.tuning().fleeHealth) {
1446
+ this.opts.diag?.write({
1447
+ lane: 'combat', event: 'stand-down-deferred',
1448
+ msg: `queue:"now" 未夺走救命动作:${retreatStillSaving ? '撤退中刚挨过打' : `生命 ${this.hp()}/20`}`,
1449
+ data: { state: this.state, health: this.bot?.health, hits: this.retreatHits },
1450
+ });
1451
+ return null;
1452
+ }
1453
+ const what = '正在跟怪打';
1454
+ this.cooldownUntil = Date.now() + STAND_DOWN_GRACE_MS;
1455
+ this.opts.diag?.write({
1456
+ lane: 'combat', event: 'stand-down',
1457
+ msg: `queue:"now" 夺手:${what},当场交还身体,${STAND_DOWN_GRACE_MS / 1000}s 内不自动进场`,
1458
+ data: { was: this.state, graceMs: STAND_DOWN_GRACE_MS },
1459
+ });
1460
+ this.opts.emit('先不打了,这件事更急。', false, false);
1461
+ this.end('preempt');
1462
+ return what;
1463
+ }
1464
+
1465
+ /** mc_stop:收手 + 撤退(原地站住等于送死)。返回给工具回执的一句话 */
1466
+ requestStop(): string | null {
1467
+ if (this.state === 'idle') return null;
1468
+ if (this.state === 'retreating') return '正在撤了';
1469
+ this.beginRetreat('mc_stop');
1470
+ return '收手了,正在撤离';
1471
+ }
1472
+
1473
+ private endClear(): void {
1474
+ const totalKills = [...this.kills.values()].reduce((a, b) => a + b, 0);
1475
+ const what = totalKills > 0
1476
+ ? `打完了,干掉 ${[...this.kills].map(([n, c]) => `${zhEntity(n)}${c > 1 ? `×${c}` : ''}`).join('、')}。`
1477
+ : '它们散了,没打起来。';
1478
+ this.opts.emit(
1479
+ `${what}刀 ${this.swings} 次命中 ${this.meleeLanded()} 次,` +
1480
+ `箭 ${this.arrows} 支命中 ${this.rangedLanded} 次;生命 ${this.hp()}/20。`,
1481
+ true,
1482
+ false,
1483
+ );
1484
+ this.end('clear');
1485
+ }
1486
+
1487
+ private busyRatioNow(): number {
1488
+ const now = Date.now();
1489
+ const from = now - BUSY_WINDOW_MS;
1490
+ while (this.busyLog.length > 0 && this.busyLog[0].to < from) this.busyLog.shift();
1491
+ let busy = 0;
1492
+ for (const b of this.busyLog) busy += Math.min(b.to, now) - Math.max(b.from, from);
1493
+ return busy / BUSY_WINDOW_MS;
1494
+ }
1495
+
1496
+ private end(reason: EndReason): void {
1497
+ if (this.state === 'idle') return;
1498
+ this.cancelRanged();
1499
+ const bot = this.bot;
1500
+ this.state = 'idle';
1501
+ this.desperate = false;
1502
+ this.retreatBank = null;
1503
+ this.unhook();
1504
+ if (bot?.entity) {
1505
+ this.release(bot);
1506
+ // 统一交还身体;releaseBody 保留反射登记的逃生目标。
1507
+ releaseBody(bot, `战斗收工(${reason})`, this.opts.diag, 'combat');
1508
+ }
1509
+ this.pathing = false;
1510
+ this.busyLog.push({ from: this.startedAt, to: Date.now() });
1511
+ this.provoked.clear();
1512
+ // 够不着收工的怪下一拍还在圈里:不设冷却就是 1 秒一开(见 STUCK_COOLDOWN_MS)
1513
+ if (reason === 'stuck') this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + STUCK_COOLDOWN_MS);
1514
+ this.opts.diag?.write({
1515
+ lane: 'combat', event: 'end',
1516
+ msg: `收工(${reason}),${Math.round((Date.now() - this.startedAt) / 1000)}s,` +
1517
+ `击杀 ${[...this.kills.values()].reduce((a, b) => a + b, 0)},` +
1518
+ `挥刀 ${this.swings} 次命中 ${this.meleeLanded()} 次,` +
1519
+ `放箭 ${this.arrows} 支命中 ${this.rangedLanded} 次,生命 ${this.hp()}/20`,
1520
+ data: {
1521
+ reason, ms: Date.now() - this.startedAt, kills: Object.fromEntries(this.kills),
1522
+ swings: this.swings, arrows: this.arrows, meleeLanded: this.meleeLanded(),
1523
+ rangedLanded: this.rangedLanded, landed: this.landed,
1524
+ },
1525
+ });
1526
+ // 只有正常战斗收尾解冻断点。断线、环境夺权、外部抢占与死亡各自由其终态边界
1527
+ // 处理冻结队列;在这里泵回会让任务进入已断线或正在交权的执行器。
1528
+ const resumable = reason === 'clear' || reason === 'flee' || reason === 'timeout' ||
1529
+ reason === 'stuck' || reason === 'mc_stop' || reason === 'busy';
1530
+ const resumed = resumable ? this.opts.resumeTasks() : null;
1531
+ if (resumed) {
1532
+ this.opts.emit(`${resumed}。`, false, false);
1533
+ }
1534
+ }
1535
+
1536
+ private release(bot: Bot): void {
1537
+ // sneak 也在名单里:寻路器交互放置失败时是**故意**留着它的
1538
+ // (index.js:557-566 只在放成那一支才 sneak=false),战斗夺手正好夹在中间,
1539
+ // 于是"战后一直半蹲着走"的残留没人收
1540
+ for (const k of ['forward', 'back', 'left', 'right', 'sprint', 'jump', 'sneak'] as const) {
1541
+ bot.setControlState(k, false);
1542
+ }
1543
+ this.lowerShield(bot);
1544
+ }
1545
+
1546
+ private cancelRanged(): void {
1547
+ this.rangedOwner = null;
1548
+ this.rangedPendingOwner = null;
1549
+ this.hybridWeapon = 'melee';
1550
+ this.opts.ranged?.abort();
1551
+ }
1552
+ }