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,1180 @@
1
+ /**
2
+ * 合成、放置与挖掘以服务端确认为准。
3
+ * 合成产物仅采用服务端槽位更新;放置仅在目标格回读成功后完成;
4
+ * 挖掘只认服务端把那一格改掉,mineflayer 到点自己写空气的那一笔按下不发。
5
+ * 全局 stateId 只认当前打开窗口的包;windowId=-2 的 `set_slot` 没有对应容器,整包拦截。
6
+ *
7
+ * 合成期间光标必须随时可以清空:prismarine-windows 对"手上拿着 A 去点装着 B 的格子"
8
+ * 一律本地换位,原版服务端不换。一次换位两边状态就此分家,之后所有合成都在错的格子上做。
9
+ *
10
+ * 光照段随包到来时必须按 nibble 原序落位,prismarine-chunk 把它当长整型数组读了。
11
+ */
12
+ import type { Bot } from 'mineflayer';
13
+ import type { Logger } from '../../core/types.ts';
14
+ import type { MinecraftLog } from './log.ts';
15
+ import { readEnchants, type ItemLike } from './item-facts.ts';
16
+ import { blockStateItem } from './blueprint-registry.ts';
17
+ import { setNameRegistry, zhName } from './names.ts';
18
+ import { PLACE_MISS_TTL_MS } from './pathfinder-perf.ts';
19
+ import { ShowPacer, type ShowTempo } from './show.ts';
20
+ import { dropOwnedGoal } from './executor.ts';
21
+
22
+ /** 单次点击等待服务端确认的上限。 */
23
+ const CLICK_ACK_MS = 400;
24
+ /** 摆好材料之后等产出槽被服务端填上的上限 */
25
+ const RESULT_WAIT_MS = 1_500;
26
+ /** 右键工作台到窗口开出来的上限 */
27
+ const WINDOW_OPEN_MS = 2_000;
28
+ /** 单次放置等待目标格变化的上限。 */
29
+ const PLACE_CONFIRM_MS = 400;
30
+ /** 一次放置最多重发几遍 */
31
+ const PLACE_TRIES = 3;
32
+ /** 挖掘等服务端把那一格改掉的上限:按 digTime 放大,给服务端的补挖留出余量 */
33
+ const DIG_CONFIRM_MIN_MS = 8_000;
34
+ const DIG_CONFIRM_FACTOR = 3;
35
+ /** 悬空起挖等落地的上限:一格下落 ~250ms 足够;水里 onGround 恒假,不等 */
36
+ const DIG_GROUND_WAIT_MS = 600;
37
+ /** 服务端认账比本地定时器晚这么多就记一条:整片挖掘不刷屏,慢的那些留痕 */
38
+ const DIG_SLOW_ACK_MS = 200;
39
+
40
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
41
+
42
+ /** 合成配方在这里只用得到这几项;prismarine-recipe 实例的平面形状 */
43
+ interface CraftRecipe {
44
+ /** `id: null` = 不预设产物:她自己摆的格子,产出槽出什么就是什么 */
45
+ result: { id: number | null; count: number; metadata?: number | null };
46
+ /** 行内空位写 null(prismarine 用 id:-1,两种都认) */
47
+ inShape?: Array<Array<{ id: number } | null>> | null;
48
+ ingredients?: Array<{ id: number }> | null;
49
+ requiresTable: boolean;
50
+ }
51
+
52
+ /** 挖掘目标方块;免得为一个类型引入 prismarine-block 依赖 */
53
+ type DigBlock = Bot['targetDigBlock'];
54
+
55
+ /** prismarine-windows 的 Window,只标出这里用到的面 */
56
+ interface WindowLike {
57
+ id: number;
58
+ type: string;
59
+ slots: Array<{ type: number; count: number; metadata?: number | null; nbt?: unknown } | null>;
60
+ selectedItem: { type: number; count: number; metadata?: number | null; nbt?: unknown } | null;
61
+ inventoryStart: number;
62
+ inventoryEnd: number;
63
+ findInventoryItem(item: number, metadata: number | null, notFull?: boolean): { slot: number } | null;
64
+ findItemRange(
65
+ start: number, end: number, itemType: number, metadata?: number | null,
66
+ notFull?: boolean, nbt?: unknown,
67
+ ): { slot: number } | null;
68
+ firstEmptySlotRange(start: number, end: number): number | null;
69
+ }
70
+
71
+ /** 装了修补的 bot 才有的内部面 */
72
+ interface PatchedBot extends Bot {
73
+ _genericPlace(
74
+ referenceBlock: unknown, faceVector: unknown, options: Record<string, unknown>,
75
+ ): Promise<unknown>;
76
+ /** blocks.js 挂在 bot 上的方块写入口:插件从外面写本地世界只有这一条路 */
77
+ _updateBlockState(point: DigBlock['position'], stateId: number): void;
78
+ /**
79
+ * 已确认放置的方块台账(环形,留最近 256 条)。寻路器垫脚/搭路也走 placeBlock,
80
+ * 执行器按步骤前后的长度差把"路上放了什么"写进回执。
81
+ */
82
+ placedLedger?: Array<{ name: string; x: number; y: number; z: number }>;
83
+ /**
84
+ * 三次放置均未确认的落点,was 为回读方块名。
85
+ * 直接技能将其写入回执;寻路另记 pathSupportFailure 并终止当前移动段。
86
+ */
87
+ placeMisses?: Array<{ was: string; x: number; y: number; z: number; at: number }>;
88
+ /** 最近一次寻路支撑未确认;执行器按 seq 区分本段路径与旧失败。 */
89
+ pathSupportFailure?: { seq: number; generation: number; was: string; x: number; y: number; z: number };
90
+ /**
91
+ * 此刻有几笔寻路支撑放置在飞。纯诊断读数(零位移探针要的那一格),不参与任何判据 ——
92
+ * 所有权已经改成按 flight 记,不再有"当前那一次"这种全局单槽。
93
+ */
94
+ pathPlacementActive?: number;
95
+ }
96
+
97
+ /**
98
+ * 必须通过 `bot.loadPlugin` 在内建插件之后注入。`inject_allowed` 之前的替换会被
99
+ * `craft.js` 覆盖;安装成功写入确认日志。
100
+ */
101
+ export function installMineflayerFixes(
102
+ bot: Bot,
103
+ log: Logger,
104
+ diag?: MinecraftLog,
105
+ showTempo?: () => ShowTempo | null,
106
+ ): void {
107
+ if (typeof bot.craft !== 'function' || typeof bot.placeBlock !== 'function') {
108
+ log.error(
109
+ 'mineflayer 修补装得太早:bot.craft/placeBlock 还不存在,说明内建插件尚未注入,' +
110
+ '这次替换会被随后的注入覆盖掉。应经 bot.loadPlugin 装,别在 createBot 之后直接调',
111
+ );
112
+ }
113
+
114
+ /**
115
+ * 仅在合成和放置窗口记录包流,避免高频 set_slot 覆盖诊断信号。
116
+ * 而要看的恰恰是"这两件事发生的那几百毫秒里,服务端往回说了什么"。
117
+ */
118
+ let tracing = 0;
119
+ const trace = (event: string, msg: string, data?: Record<string, unknown>): void => {
120
+ if (tracing <= 0) return;
121
+ diag?.write({ lane: 'craft', event, msg, data });
122
+ };
123
+
124
+ // 中文名拼名器在这里才第一次拿得到 registry:拼得出中文名 ≠ 这个 id 存在,
125
+ // 装上之后 `deepslate_cobblestone` 那类假 id 会在回执里带出原始 id(names.ts 模块头)
126
+ setNameRegistry(bot.registry as unknown as Parameters<typeof setNameRegistry>[0] | undefined);
127
+ fixFoodComponentSchema(bot, log);
128
+ fixPotionContentsSchema(bot, log);
129
+ fixToolTierMaterials(bot, log);
130
+ installComponentDigTime(bot);
131
+ installStateIdGuard(bot, diag, () => tracing > 0);
132
+ installPacketTrace(bot, trace);
133
+ installConfirmedPlace(bot as PatchedBot, diag, (n) => { tracing += n; });
134
+ installConfirmedDig(bot as PatchedBot, log, diag);
135
+ installDiggingLatchRelease(bot, diag);
136
+ installConfirmedCraft(bot, diag, trace, (n) => { tracing += n; }, showTempo);
137
+ installLightRelay(bot, log, diag);
138
+ installDismountFix(bot, diag);
139
+ log.info(`mineflayer 修补已装上:合成取服务端产物、放置短超时重发、附魔按组件格式计入挖掘、挖掘等服务端改掉那一格、stateId 认当前窗口、光照段重新落位${diag ? '' : '(没给 World 日志,包流不留痕)'}`);
140
+ }
141
+
142
+ /**
143
+ * set_passengers 中当前载具的乘客名单不再包含自己时,清空 bot.vehicle。
144
+ * 此补丁补齐 mineflayer 仅处理上车名单的下车路径。
145
+ */
146
+ function installDismountFix(bot: Bot, diag?: MinecraftLog): void {
147
+ const client = bot._client as unknown as {
148
+ on(name: string, fn: (p: { entityId: number; passengers: number[] }) => void): void;
149
+ };
150
+ client.on('set_passengers', ({ entityId, passengers }) => {
151
+ const vehicle = (bot as unknown as { vehicle: { id?: number } | null }).vehicle;
152
+ if (!vehicle || vehicle.id !== entityId) return;
153
+ if (passengers.includes(bot.entity.id)) return;
154
+ (bot as unknown as { vehicle: unknown }).vehicle = null;
155
+ bot.emit('dismount' as never, vehicle as never);
156
+ diag?.write({ lane: 'skill', event: 'dismount-fix', msg: '载具乘客名单里不再有自己,补清 bot.vehicle(mineflayer 只处理上车那一半)' });
157
+ });
158
+ }
159
+
160
+ type PathfinderBlock = {
161
+ digTime(
162
+ itemType: number | null,
163
+ creative: boolean,
164
+ inWater: boolean,
165
+ notOnGround: boolean,
166
+ enchantments: Array<{ name: string; lvl: number }>,
167
+ effects: unknown,
168
+ ): number;
169
+ };
170
+
171
+ /**
172
+ * 寻路挖掘只在新工具严格更快时换手,同速时返回当前手持物。
173
+ * 规划侧将 null 按徒手成本计价;返回当前物品可保持正确成本,执行侧同槽 equip 不发包。
174
+ */
175
+ export function installPathfinderToolSelection(bot: Bot, log: Logger): void {
176
+ const pathfinder = (bot as unknown as {
177
+ pathfinder?: { bestHarvestTool?: (block: PathfinderBlock) => ItemLike | null };
178
+ }).pathfinder;
179
+ if (typeof pathfinder?.bestHarvestTool !== 'function') {
180
+ log.warn('寻路选工具修补没装上:pathfinder 尚未注入');
181
+ return;
182
+ }
183
+
184
+ const hotbarStart = (bot.inventory as unknown as { hotbarStart?: number }).hotbarStart ?? 36;
185
+ const inHotbar = (item: ItemLike & { slot?: number }): boolean =>
186
+ typeof item.slot === 'number' && item.slot >= hotbarStart && item.slot < hotbarStart + 9;
187
+ const digTime = (block: PathfinderBlock, item: (ItemLike & { type: number }) | null): number =>
188
+ block.digTime(
189
+ item?.type ?? null,
190
+ false,
191
+ false,
192
+ false,
193
+ item ? readEnchants(item, bot.registry).map((entry) => ({ name: entry.name, lvl: entry.level })) : [],
194
+ bot.entity.effects,
195
+ );
196
+
197
+ pathfinder.bestHarvestTool = (block): ItemLike | null => {
198
+ const held = bot.heldItem as (ItemLike & { type: number; slot?: number }) | null;
199
+ // 空手时基线就是 null,规划侧读成徒手、执行侧读成不换手,两边都正确
200
+ let best: (ItemLike & { type: number; slot?: number }) | null = held;
201
+ let fastest = digTime(block, held);
202
+ for (const item of bot.inventory.items() as Array<ItemLike & { type: number; slot?: number }>) {
203
+ if (held && item.slot === held.slot) continue;
204
+ const time = digTime(block, item);
205
+ // 同速那一支只为把选择挪进快捷栏:手上那把已在快捷栏(常态)就不动,免得洗背包
206
+ if (time < fastest || (time === fastest && best !== null && !inHotbar(best) && inHotbar(item))) {
207
+ fastest = time;
208
+ best = item;
209
+ }
210
+ }
211
+ return best;
212
+ };
213
+ log.info('寻路选工具修正:只有严格加快挖掘才换手,同速原样回报手上那把(计价按它算,执行侧不换手)');
214
+ }
215
+
216
+ /**
217
+ * 移除 1.20.6 food 组件表中不属于该版本的 usingConvertsTo 字段,避免 trade_list 解析错位。
218
+ * 登录前修改共享协议表;字段缺席时保持原表。
219
+ */
220
+ function fixFoodComponentSchema(bot: Bot, log: Logger): void {
221
+ const food = slotComponentFields(bot, 'food');
222
+ if (!food) return;
223
+ const i = food.findIndex((f) => f?.name === 'usingConvertsTo');
224
+ if (i < 0) return;
225
+ food.splice(i, 1);
226
+ log.info('协议表修正:1.20.6 food 组件摘除 usingConvertsTo,村民报价包(trade_list)解析恢复');
227
+ }
228
+
229
+ /**
230
+ * 移除旧版本 potion_contents 表中提前出现的 customName 字段。
231
+ * 1.20.6 仅发送 potionId、customColor、customEffects;多读字段会使含药水物品的整包解析失败。
232
+ */
233
+ function fixPotionContentsSchema(bot: Bot, log: Logger): void {
234
+ const potion = slotComponentFields(bot, 'potion_contents');
235
+ if (!potion) return;
236
+ const i = potion.findIndex((f) => f?.name === 'customName');
237
+ if (i < 0) return;
238
+ potion.splice(i, 1);
239
+ log.info('协议表修正:1.20.6 potion_contents 组件摘除 customName,带药水/水瓶的物品栏包不再被静默丢弃');
240
+ }
241
+
242
+ /** SlotComponent 表里某个组件的字段数组(原地可改);表不在或形状不对给 null。 */
243
+ function slotComponentFields(bot: Bot, component: string): Array<{ name?: string }> | null {
244
+ const proto = (bot as unknown as { registry?: { protocol?: { types?: Record<string, unknown> } } }).registry?.protocol;
245
+ const slotComponent = proto?.types?.SlotComponent as
246
+ | [string, Array<{ name?: string; type?: unknown }>]
247
+ | undefined;
248
+ const data = slotComponent?.[1]?.find?.((f) => f?.name === 'data');
249
+ const fields = (data?.type as [string, { fields?: Record<string, unknown> }] | undefined)?.[1]?.fields;
250
+ const def = fields?.[component] as [string, Array<{ name?: string }>] | undefined;
251
+ return Array.isArray(def?.[1]) ? def[1] : null;
252
+ }
253
+
254
+ /**
255
+ * 按 harvestTools 的工具类别修复 incorrect_for_*_tool 被误填为 material 的注册表条目。
256
+ * 仅更新挖掘速度材质,保留 harvestTools 的掉落等级约束。
257
+ * 登录前修改共享数据;没有异常材质时不作改动。
258
+ */
259
+ function fixToolTierMaterials(bot: Bot, log: Logger): void {
260
+ const reg = (bot as unknown as {
261
+ registry?: {
262
+ blocksArray?: Array<{ material?: string; harvestTools?: Record<string, boolean> }>;
263
+ materials?: Record<string, Record<string, number>>;
264
+ items?: Record<number, { name?: string } | undefined>;
265
+ };
266
+ }).registry;
267
+ const blocks = reg?.blocksArray;
268
+ const materials = reg?.materials;
269
+ if (!Array.isArray(blocks) || !materials) return;
270
+ let fixed = 0;
271
+ const kinds = new Set<string>();
272
+ for (const b of blocks) {
273
+ if (typeof b.material !== 'string' || !b.material.startsWith('incorrect_for_')) continue;
274
+ const tools = Object.keys(b.harvestTools ?? {});
275
+ if (tools.length === 0) continue;
276
+ // 同一方块的名单必然同类;混了类别就不猜,留着原样
277
+ const kindSet = new Set(tools.map((id) => toolKindOfItem(reg?.items?.[Number(id)]?.name ?? '')));
278
+ if (kindSet.size !== 1) continue;
279
+ const kind = [...kindSet][0];
280
+ if (!materials[`mineable/${kind}`]) continue;
281
+ b.material = `mineable/${kind}`;
282
+ fixed++;
283
+ kinds.add(kind);
284
+ }
285
+ if (fixed > 0) {
286
+ log.info(
287
+ `挖掘速度表修正:${fixed} 种方块的 material 由 incorrect_for_*_tool 换回 ` +
288
+ `mineable/${[...kinds].join('|')}(铁矿配铁镐 4.55 秒→0.75 秒,不再空挥)`,
289
+ );
290
+ }
291
+ }
292
+
293
+ /** `stone_pickaxe` → pickaxe、`shears` → shears */
294
+ function toolKindOfItem(itemName: string): string {
295
+ const i = itemName.lastIndexOf('_');
296
+ return i < 0 ? itemName : itemName.slice(i + 1);
297
+ }
298
+
299
+ type DigTimeItem = ItemLike & { type: number; enchants?: unknown };
300
+
301
+ /**
302
+ * 组件时代的附魔先规范成 prismarine-block 的数组形状。
303
+ * prismarine-item 1.18.0 会把组件的整份 `{enchantments,showTooltip}` 当成 `item.enchants`
304
+ * 返回;mineflayer 4.37.1 随后对它调用 `concat`,附魔工具因此在起挖前直接抛错。
305
+ */
306
+ function installComponentDigTime(bot: Bot): void {
307
+ if (typeof bot.digTime !== 'function' || typeof bot.getEquipmentDestSlot !== 'function') return;
308
+ const original = bot.digTime.bind(bot);
309
+ const componentShaped = (item: DigTimeItem | null | undefined): boolean => {
310
+ const data = item?.componentMap?.get('enchantments')?.data;
311
+ return data !== undefined && !Array.isArray(data);
312
+ };
313
+ const normalized = (item: DigTimeItem | null | undefined): Array<{ name: string; lvl: number }> =>
314
+ item ? readEnchants(item, bot.registry).map((e) => ({ name: e.name, lvl: e.level })) : [];
315
+
316
+ bot.digTime = ((block): number => {
317
+ const held = bot.heldItem as DigTimeItem | null;
318
+ const headSlot = bot.getEquipmentDestSlot('head');
319
+ const head = bot.inventory.slots[headSlot] as DigTimeItem | null | undefined;
320
+ if (!componentShaped(held) && !componentShaped(head)) return original(block);
321
+
322
+ const eye = (bot as unknown as { _getBlockAtEyeLevel(): { name?: string } | null })._getBlockAtEyeLevel();
323
+ return block.digTime(
324
+ held?.type ?? null,
325
+ bot.game.gameMode === 'creative',
326
+ eye?.name === 'water' || eye?.name === 'flowing_water',
327
+ !bot.entity.onGround,
328
+ [...normalized(held), ...normalized(head)],
329
+ bot.entity.effects,
330
+ );
331
+ }) as Bot['digTime'];
332
+ }
333
+
334
+ /**
335
+ * 全局 stateId 只认当前打开窗口那条流。
336
+ *
337
+ * mineflayer 用任意 set_slot/window_items 的 stateId 顶掉全局值,而点击一律带
338
+ * 全局值发出;开着工作台时窗口 0 的更新(副手槽 45 是常客)会让每次点击都带上
339
+ * 错的 stateId,服务端按失步整窗回灌。windowId 与当前窗口对不上的包内容照常
340
+ * 透传,stateId 改写成当前窗口最近一次的值;windowId=-2 没有对应容器,整包拦下。
341
+ */
342
+ function installStateIdGuard(bot: Bot, diag: MinecraftLog | undefined, tracing: () => boolean): void {
343
+ const client = bot._client as unknown as {
344
+ emit(name: string, ...args: unknown[]): boolean;
345
+ };
346
+ const origEmit = client.emit.bind(client);
347
+ let dropped = 0;
348
+ let rewritten = 0;
349
+ let goodStateId: number | null = null;
350
+ client.emit = (name: string, ...args: unknown[]): boolean => {
351
+ if (name === 'set_slot' || name === 'window_items') {
352
+ const pkt = args[0] as { windowId?: number; stateId?: number; slot?: number } | undefined;
353
+ if (pkt && name === 'set_slot' && pkt.windowId === -2) {
354
+ dropped++;
355
+ if (tracing()) {
356
+ diag?.write({
357
+ lane: 'craft', event: 'stateid-guard',
358
+ msg: `拦下一个 windowId=-2 的 set_slot(它带的 stateId=${pkt.stateId})`,
359
+ data: { stateId: pkt.stateId, slot: pkt.slot, droppedSoFar: dropped },
360
+ });
361
+ }
362
+ return true;
363
+ }
364
+ if (pkt && typeof pkt.stateId === 'number') {
365
+ const curId = (bot.currentWindow as { id?: number } | null)?.id ?? 0;
366
+ if (pkt.windowId === curId) {
367
+ goodStateId = pkt.stateId;
368
+ } else if (goodStateId !== null && pkt.stateId !== goodStateId) {
369
+ rewritten++;
370
+ if (tracing()) {
371
+ diag?.write({
372
+ lane: 'craft', event: 'stateid-rewrite',
373
+ msg: `${name} 窗口${pkt.windowId} 带的 stateId=${pkt.stateId} 不属于当前窗口${curId},改回 ${goodStateId}`,
374
+ data: { windowId: pkt.windowId, was: pkt.stateId, now: goodStateId, rewrittenSoFar: rewritten },
375
+ });
376
+ }
377
+ pkt.stateId = goodStateId;
378
+ }
379
+ }
380
+ }
381
+ return origEmit(name, ...args);
382
+ };
383
+ }
384
+
385
+ /** 合成/放置那几百毫秒里的包流:发出去的点击、收回来的窗口更新 */
386
+ function installPacketTrace(
387
+ bot: Bot,
388
+ trace: (event: string, msg: string, data?: Record<string, unknown>) => void,
389
+ ): void {
390
+ const client = bot._client as unknown as {
391
+ write(name: string, params: Record<string, unknown>): void;
392
+ on(name: string, cb: (pkt: Record<string, unknown>) => void): void;
393
+ };
394
+ const origWrite = client.write.bind(client);
395
+ client.write = (name: string, params: Record<string, unknown>): void => {
396
+ if (name === 'window_click') {
397
+ const changed = params.changedSlots as unknown[] | undefined;
398
+ trace('click-out', `点击 窗口${params.windowId} 槽${params.slot} 键${params.mouseButton} 模式${params.mode}`, {
399
+ windowId: params.windowId, stateId: params.stateId, slot: params.slot,
400
+ mouseButton: params.mouseButton, mode: params.mode,
401
+ changedSlots: changed?.length ?? 0,
402
+ cursor: params.cursorItem,
403
+ });
404
+ } else if (name === 'block_place') {
405
+ trace('place-out', `放置包 → ${JSON.stringify(params.location)} 面${params.direction}`, {
406
+ location: params.location, direction: params.direction, sequence: params.sequence,
407
+ });
408
+ }
409
+ origWrite(name, params);
410
+ };
411
+ client.on('set_slot', (pkt) => {
412
+ trace('slot-in', `set_slot 窗口${pkt.windowId} 槽${pkt.slot} stateId=${pkt.stateId}`, {
413
+ windowId: pkt.windowId, stateId: pkt.stateId, slot: pkt.slot,
414
+ });
415
+ });
416
+ client.on('window_items', (pkt) => {
417
+ const items = pkt.items as unknown[] | undefined;
418
+ trace('items-in', `window_items 窗口${pkt.windowId} stateId=${pkt.stateId}(整窗回灌)`, {
419
+ windowId: pkt.windowId, stateId: pkt.stateId, count: items?.length ?? 0,
420
+ });
421
+ });
422
+ }
423
+
424
+ /** 线路上一段光照是 2048 字节的 nibble 数组:一字节两格,低半字节在前 */
425
+ const LIGHT_SECTION_BYTES = 2048;
426
+ /** BitArray 存同一段用 512 个 32 位字 */
427
+ const LIGHT_SECTION_WORDS = LIGHT_SECTION_BYTES / 4;
428
+
429
+ /** 一段光照:只用得到底下那块字。 */
430
+ interface LightSection { data: Uint32Array }
431
+
432
+ /** 随 map_chunk / update_light 一起到的光照字段 */
433
+ interface LightPacket {
434
+ skyLight?: unknown;
435
+ blockLight?: unknown;
436
+ skyLightMask?: unknown;
437
+ blockLightMask?: unknown;
438
+ emptySkyLightMask?: unknown;
439
+ emptyBlockLightMask?: unknown;
440
+ }
441
+
442
+ /** 装了光照段的列;`sections[i]` 的 i 与掩码位一一对应(0 是比世界底还低的那层) */
443
+ interface LightColumn {
444
+ skyLightSections: Array<LightSection | null>;
445
+ blockLightSections: Array<LightSection | null>;
446
+ }
447
+
448
+ /**
449
+ * 掩码位。protodef 把 i64 解成 [高 32 位, 低 32 位] 一对;掩码只有二十几位,
450
+ * 但位号仍按 64 位一格数。
451
+ */
452
+ function maskBit(mask: unknown, index: number): boolean {
453
+ if (!Array.isArray(mask)) return false;
454
+ const word: unknown = mask[index >> 6];
455
+ const bit = index & 63;
456
+ if (typeof word === 'bigint') return ((word >> BigInt(bit)) & 1n) === 1n;
457
+ if (!Array.isArray(word)) return false;
458
+ const [hi, lo] = word as [number, number];
459
+ return bit >= 32 ? ((hi >>> (bit - 32)) & 1) === 1 : ((lo >>> bit) & 1) === 1;
460
+ }
461
+
462
+ /**
463
+ * 把随包来的光照字节按原序重写进已装好的段里,返回重写了几段。
464
+ *
465
+ * 走法要与上游一字不差:掩码位与"全零位"都没有的段跳过(包里没这段),
466
+ * 全零段包里也不带数据数组,只有掩码位为 1 的段才按序取走一份。
467
+ */
468
+ function relightSections(
469
+ sections: Array<LightSection | null>, arrays: unknown, mask: unknown, emptyMask: unknown,
470
+ ): number {
471
+ if (!Array.isArray(arrays)) return 0;
472
+ let next = 0;
473
+ let fixed = 0;
474
+ for (let i = 0; i < sections.length; i++) {
475
+ const empty = maskBit(emptyMask, i);
476
+ if (!maskBit(mask, i) && !empty) continue;
477
+ if (empty) continue;
478
+ const raw = arrays[next++] as ArrayLike<number> | undefined;
479
+ const section = sections[i];
480
+ if (!section || !raw || raw.length !== LIGHT_SECTION_BYTES) continue;
481
+ if (!(section.data instanceof Uint32Array) || section.data.length !== LIGHT_SECTION_WORDS) continue;
482
+ // 小端机上 Uint32Array 视图的字节序就是 nibble 的原序:这正是上游读存盘光照
483
+ // (_loadBlockLightNibbles)用的那套布局,读回来的 get(i) 才落在第 i 格上
484
+ const bytes = new Uint8Array(LIGHT_SECTION_BYTES);
485
+ bytes.set(raw);
486
+ section.data.set(new Uint32Array(bytes.buffer));
487
+ fixed++;
488
+ }
489
+ return fixed;
490
+ }
491
+
492
+ /**
493
+ * 光照段按原始 nibble 顺序写入,每段为 2048 字节。
494
+ * 覆盖上游按大端长整型读入的布局,避免每八字节倒序导致坐标错配。
495
+ */
496
+ function installLightRelay(bot: Bot, log: Logger, diag?: MinecraftLog): void {
497
+ const client = bot._client as unknown as {
498
+ on(name: string, cb: (pkt: LightPacket & Record<string, unknown>) => void): void;
499
+ };
500
+ let announced = false;
501
+ let warned = false;
502
+ const apply = (cx: number, cz: number, pkt: LightPacket): void => {
503
+ // bot.world 要到 login 包才创建(晚于插件注入),只能在事件时刻取——这两个包都在 login 之后
504
+ const world = bot.world as unknown as { getColumn(x: number, z: number): LightColumn | null } | undefined;
505
+ if (typeof world?.getColumn !== 'function') {
506
+ if (!warned) {
507
+ warned = true;
508
+ log.warn('bot.world 没有 getColumn,光照段重新落位没生效:亮度读数不可信');
509
+ }
510
+ return;
511
+ }
512
+ const column = world.getColumn(cx, cz);
513
+ if (!column) return;
514
+ const fixed =
515
+ relightSections(column.skyLightSections, pkt.skyLight, pkt.skyLightMask, pkt.emptySkyLightMask) +
516
+ relightSections(column.blockLightSections, pkt.blockLight, pkt.blockLightMask, pkt.emptyBlockLightMask);
517
+ if (fixed === 0 || announced) return;
518
+ announced = true;
519
+ diag?.write({
520
+ lane: 'link', event: 'light-relay',
521
+ msg: `光照段已按 nibble 原序重写(首个区块 ${cx},${cz} 改了 ${fixed} 段)`,
522
+ data: { chunkX: cx, chunkZ: cz, sections: fixed },
523
+ });
524
+ };
525
+ client.on('map_chunk', (pkt) => apply(Number(pkt.x), Number(pkt.z), pkt));
526
+ client.on('update_light', (pkt) => apply(Number(pkt.chunkX), Number(pkt.chunkZ), pkt));
527
+ log.debug('光照段重新落位已挂上 map_chunk/update_light');
528
+ }
529
+
530
+ /**
531
+ * 放置方块使用短超时和原位重试,避免触发寻路器全量重算。
532
+ *
533
+ * 重发是安全的:每次先回读那一格,已经变了就直接收工;真的放成了而回包慢的话,
534
+ * 第二次的放置目标已经被自己的方块占着,服务端照样不会放出第二块。
535
+ */
536
+ function installConfirmedPlace(
537
+ bot: PatchedBot, diag: MinecraftLog | undefined, setTracing: (delta: number) => void,
538
+ ): void {
539
+ let pathGeneration = 0;
540
+ interface PathPlacementFlight {
541
+ promise: Promise<void>;
542
+ joined: number;
543
+ generation: number;
544
+ at: { x: number; y: number; z: number };
545
+ }
546
+ const pathPlacementFlights = new Map<string, PathPlacementFlight>();
547
+ bot.on('goal_updated', () => { pathGeneration += 1; });
548
+ // 代次 = 「还是不是原来那一段路」。换目标只是换代的一半:同一个目标内上游还会因
549
+ // stuck / block_updated / chunk_loaded / dig_error 反复 resetPath 重铺路,旧路上的
550
+ // 支撑那时已经作废,它迟到的失败不该去撤新铺的那一条。`path_reset`(index.js:124)
551
+ // 是这些重铺唯一的外部信号。它只在 `path.length > 0` 时发 —— 路本来就是空的那几次
552
+ // 不换代,但那种时候也没有支撑在飞,漏不着。
553
+ bot.on('path_reset', () => { pathGeneration += 1; });
554
+ bot.placeBlock = async (referenceBlock, faceVector): Promise<void> => {
555
+ // `async` 不能省:`referenceBlock.position.plus(...)` 的参数异常在同步函数里会
556
+ // 当场 throw,而调用方(含 pathfinder 自己)一律按 rejected promise 接 `.catch()`,
557
+ // 同步抛出全部漏接。函数体内没有 await,下面的合并窗口仍然是同步的。
558
+ const dest = referenceBlock.position.plus(faceVector);
559
+ const generation = bot.pathfinder?.isBuilding?.() === true ? pathGeneration : null;
560
+ if (generation === null) return placeConfirmed(referenceBlock, faceVector, dest, null);
561
+
562
+ // 合并键只认「哪一代的哪一格」,不认参照面:同一格从两个面放,先到的那次放成了
563
+ // 第二次也无事可做(回读已经变了),放不成则两个面多半同因(那一格被占/够不着);
564
+ // 把参照面纳入键会让两次真的并行发包,同一格扣两份料,正是合并要挡的事。
565
+ // 代价是"A 面放不成、B 面本来能放成"那一支会被一起判失败,失败侧本就重试三次并
566
+ // 每次回读,损失有界。
567
+ const key = `${generation}:${dest.x},${dest.y},${dest.z}`;
568
+ const current = pathPlacementFlights.get(key);
569
+ if (current) {
570
+ current.joined += 1;
571
+ return current.promise;
572
+ }
573
+
574
+ const flight: PathPlacementFlight = {
575
+ promise: Promise.resolve(), joined: 0, generation,
576
+ at: { x: dest.x, y: dest.y, z: dest.z },
577
+ };
578
+ bot.pathPlacementActive = (bot.pathPlacementActive ?? 0) + 1;
579
+ flight.promise = placeConfirmed(referenceBlock, faceVector, dest, { key, flight }).finally(() => {
580
+ bot.pathPlacementActive = Math.max(0, (bot.pathPlacementActive ?? 1) - 1);
581
+ if (pathPlacementFlights.get(key) === flight) pathPlacementFlights.delete(key);
582
+ if (flight.joined > 0) {
583
+ diag?.write({
584
+ lane: 'skill', event: 'path-support-coalesced',
585
+ msg: `寻路支撑 (${flight.at.x}, ${flight.at.y}, ${flight.at.z}) 的 ${flight.joined + 1} 个重叠请求合并成一次放置`,
586
+ data: { generation: flight.generation, at: flight.at, calls: flight.joined + 1 },
587
+ });
588
+ }
589
+ });
590
+ pathPlacementFlights.set(key, flight);
591
+ return flight.promise;
592
+ };
593
+
594
+ async function placeConfirmed(
595
+ referenceBlock: Parameters<PatchedBot['placeBlock']>[0],
596
+ faceVector: Parameters<PatchedBot['placeBlock']>[1],
597
+ dest: Parameters<Bot['blockAt']>[0],
598
+ pathPlacement: { key: string; flight: PathPlacementFlight } | null,
599
+ ): Promise<void> {
600
+ const before = bot.blockAt(dest);
601
+ const startedAt = Date.now();
602
+ // 直接技能复用放置负缓存:同格、方块名未变且在 TTL 内则拒绝重试。
603
+ // 寻路放置已在搜索层排除,仍通过 flight 记录确认与所有权。
604
+ if (pathPlacement === null) {
605
+ const stale = recentPlaceMiss(bot, dest);
606
+ if (stale !== null) {
607
+ diag?.write({
608
+ lane: 'skill', event: 'place-miss-cached',
609
+ msg: `(${dest.x}, ${dest.y}, ${dest.z}) ${Math.round(stale.agoMs / 1000)} 秒前放过三次都没确认,`
610
+ + `那一格现在还是${zhName(stale.was)}:这次不发包`,
611
+ data: { at: { x: dest.x, y: dest.y, z: dest.z }, was: stale.was, agoMs: stale.agoMs },
612
+ });
613
+ throw new Error(`No block has been placed : the block is still ${stale.was}`);
614
+ }
615
+ }
616
+ // 期望落地的是哪一样:`_genericPlace` 放的就是手上这件。取得到方块名才校验身份,
617
+ // 取不到(水桶、红石粉、种子这类"物品名 ≠ 方块名"的)一律降级为旧口径并在 diag 标注
618
+ const want = placedBlockExpectation(bot);
619
+ setTracing(1);
620
+ try {
621
+ for (let attempt = 1; attempt <= PLACE_TRIES; attempt++) {
622
+ // 重试前回读目标位置;前次放置的迟到回包不得触发重复放置。
623
+ const ok = attempt > 1 && changedAt(bot, dest, before, want)
624
+ ? true
625
+ : await placeOnce(bot, referenceBlock, faceVector, dest, before, want);
626
+ if (ok) {
627
+ diag?.write({
628
+ lane: 'skill', event: 'place-confirmed', durMs: Date.now() - startedAt,
629
+ msg: `放置在第 ${attempt} 次回读到了 (${dest.x}, ${dest.y}, ${dest.z})`
630
+ + (want === null ? '(没校验落地的是不是要放的那样)' : ''),
631
+ data: {
632
+ attempt, at: { x: dest.x, y: dest.y, z: dest.z },
633
+ want, identityChecked: want !== null, placed: bot.blockAt(dest)?.name ?? null,
634
+ },
635
+ });
636
+ const placed = bot.blockAt(dest);
637
+ if (placed) {
638
+ const ledger = (bot.placedLedger ??= []);
639
+ ledger.push({ name: placed.name, x: dest.x, y: dest.y, z: dest.z });
640
+ if (ledger.length > 256) ledger.splice(0, ledger.length - 256);
641
+ }
642
+ // 保持 placeBlock 的 blockPlaced 事件契约;mineflayer 类型表未声明该事件。
643
+ (bot as unknown as { emit(n: string, ...a: unknown[]): void })
644
+ .emit('blockPlaced', before, bot.blockAt(dest));
645
+ return;
646
+ }
647
+ }
648
+ } finally {
649
+ setTracing(-1);
650
+ }
651
+ const was = before?.name ?? 'air';
652
+ const misses = (bot.placeMisses ??= []);
653
+ // at 供寻路器的被拒格黑名单判时效(pathfinder-perf 模块头第 8 条)
654
+ misses.push({ was, x: dest.x, y: dest.y, z: dest.z, at: Date.now() });
655
+ if (misses.length > 256) misses.splice(0, misses.length - 256);
656
+ // 寻路支撑未获服务端确认时撤销目标,避免再次使用未成立的承重条件。
657
+ // 每次放置由自己的 flight 持有所有权;代次校验用于排除迟到结果。
658
+ const owns = pathPlacement !== null
659
+ && pathPlacementFlights.get(pathPlacement.key) === pathPlacement.flight
660
+ && pathGeneration === pathPlacement.flight.generation;
661
+ if (owns) {
662
+ const seq = (bot.pathSupportFailure?.seq ?? 0) + 1;
663
+ bot.pathSupportFailure = {
664
+ seq, generation: pathPlacement.flight.generation, was,
665
+ x: dest.x, y: dest.y, z: dest.z,
666
+ };
667
+ // 撤的是谁的目标要说得出来:这一路与 executor/combat/反射共用同一本所有权账
668
+ dropOwnedGoal(bot, 'path-support', `搭路支撑 (${dest.x}, ${dest.y}, ${dest.z}) 三次未确认`, diag);
669
+ diag?.write({
670
+ lane: 'skill', event: 'path-support-unconfirmed',
671
+ msg: `寻路支撑 (${dest.x}, ${dest.y}, ${dest.z}) 未确认,已取消当前移动段`,
672
+ data: {
673
+ seq, generation: pathPlacement.flight.generation, was,
674
+ at: { x: dest.x, y: dest.y, z: dest.z },
675
+ },
676
+ });
677
+ }
678
+ // 现场几何随案卷:拒放的规律(台架实测"越贴身越拒",脚下低一格 0%)要靠
679
+ // 人在哪、离目标多远、有没有潜行这几个数才能对上号,只有坐标断不了案
680
+ const feet = bot.entity?.position;
681
+ const eye = feet ? { x: feet.x, y: feet.y + 1.62, z: feet.z } : null;
682
+ diag?.write({
683
+ lane: 'skill', event: 'place-unconfirmed', durMs: Date.now() - startedAt,
684
+ msg: `放了 ${PLACE_TRIES} 次,(${dest.x}, ${dest.y}, ${dest.z}) 回读`
685
+ + (want !== null && bot.blockAt(dest)?.name !== (before?.name ?? null)
686
+ ? `变成了${bot.blockAt(dest)?.name ?? '空气'},不是要放的 ${want}`
687
+ : `还是${before?.name ?? '空气'}`),
688
+ data: {
689
+ at: { x: dest.x, y: dest.y, z: dest.z },
690
+ was: before?.name ?? null,
691
+ want, identityChecked: want !== null, now: bot.blockAt(dest)?.name ?? null,
692
+ feet: feet ? { x: Number(feet.x.toFixed(2)), y: Number(feet.y.toFixed(2)), z: Number(feet.z.toFixed(2)) } : null,
693
+ eyeDist: eye
694
+ ? Number(Math.hypot(eye.x - (dest.x + 0.5), eye.y - (dest.y + 0.5), eye.z - (dest.z + 0.5)).toFixed(2))
695
+ : null,
696
+ sneak: (bot as unknown as { controlState?: Record<string, boolean> }).controlState?.sneak ?? null,
697
+ face: faceVector && typeof faceVector === 'object'
698
+ ? { x: (faceVector as { x: number }).x, y: (faceVector as { y: number }).y, z: (faceVector as { z: number }).z }
699
+ : null,
700
+ held: bot.heldItem?.name ?? null,
701
+ },
702
+ });
703
+ // 保持 mineflayer 错误文本,供寻路器沿兼容的 catch 分支处理。
704
+ throw new Error(`No block has been placed : the block is still ${before?.name}`);
705
+ }
706
+ }
707
+
708
+ async function placeOnce(
709
+ bot: PatchedBot,
710
+ referenceBlock: unknown,
711
+ faceVector: unknown,
712
+ dest: { x: number; y: number; z: number },
713
+ before: { type: number } | null,
714
+ want: string | null,
715
+ ): Promise<boolean> {
716
+ await bot._genericPlace(referenceBlock, faceVector, { swingArm: 'right' });
717
+ return waitBlockChanged(bot, dest, before, PLACE_CONFIRM_MS, want);
718
+ }
719
+
720
+ /**
721
+ * 手上这件东西放下去应当变成哪个方块名;答不出返回 null。
722
+ *
723
+ * 只在 registry 里 `blocksByName` 直接有同名方块时答得出 —— 水桶→水、红石粉→红石线、
724
+ * 种子→小麦这类"物品名 ≠ 方块名"的一律不答,宁可退回旧口径也不能把真放置判成失败。
725
+ */
726
+ function placedBlockExpectation(bot: Bot): string | null {
727
+ const held = (bot.heldItem as { name?: string } | null)?.name;
728
+ if (!held) return null;
729
+ const blocks = (bot.registry as unknown as { blocksByName?: Record<string, unknown> } | null)?.blocksByName;
730
+ return blocks?.[held] === undefined ? null : held;
731
+ }
732
+
733
+ /**
734
+ * 确认落地方块与目标物品同名,或映射到同一物品形态。
735
+ * want 为 null 时只能确认目标格发生变化,不能断言材料身份。
736
+ */
737
+ function placedAsWanted(name: string, want: string): boolean {
738
+ if (name === want) return true;
739
+ if (!name) return false;
740
+ return blockStateItem(`minecraft:${name}`).item === want;
741
+ }
742
+
743
+ function changedAt(
744
+ bot: Bot,
745
+ dest: { x: number; y: number; z: number },
746
+ before: { type: number } | null,
747
+ want: string | null = null,
748
+ ): boolean {
749
+ const now = bot.blockAt(dest as never) as { type: number; name: string } | null;
750
+ if (want !== null) return now !== null && placedAsWanted(now.name, want);
751
+ if (!now || !before) return now !== before;
752
+ return now.type !== before.type;
753
+ }
754
+
755
+ async function waitBlockChanged(
756
+ bot: Bot, dest: { x: number; y: number; z: number }, before: { type: number } | null, ms: number,
757
+ want: string | null = null,
758
+ ): Promise<boolean> {
759
+ const deadline = Date.now() + ms;
760
+ for (;;) {
761
+ if (changedAt(bot, dest, before, want)) return true;
762
+ if (Date.now() >= deadline) return false;
763
+ await sleep(20);
764
+ }
765
+ }
766
+
767
+ /**
768
+ * 这一格最近有没有被三连拒过、且还是当时那种方块。有就返回那一笔,没有返回 null。
769
+ * 判据与寻路搜索黑名单(pathfinder-perf `getNeighbors`)逐字同一套,共用一个 TTL 常量。
770
+ */
771
+ function recentPlaceMiss(
772
+ bot: PatchedBot, dest: { x: number; y: number; z: number },
773
+ ): { was: string; agoMs: number } | null {
774
+ const misses = bot.placeMisses;
775
+ if (!misses || misses.length === 0) return null;
776
+ const now = Date.now();
777
+ // 同一格可能有多笔,以最近的一笔为准
778
+ for (let i = misses.length - 1; i >= 0; i--) {
779
+ const m = misses[i];
780
+ if (m.x !== dest.x || m.y !== dest.y || m.z !== dest.z) continue;
781
+ const agoMs = now - m.at;
782
+ if (agoMs > PLACE_MISS_TTL_MS) return null;
783
+ // 世界变过了就放行重试:黑名单挡的是"什么都没变的重试",不是这一格本身
784
+ return (bot.blockAt(dest as never)?.name ?? 'air') === m.was ? { was: m.was, agoMs } : null;
785
+ }
786
+ return null;
787
+ }
788
+
789
+ /**
790
+ * 挖掘以服务端改掉那一格为准。
791
+ *
792
+ * mineflayer 的挖掘是开环的:发一次 start,按 `bot.digTime` 起一个本地定时器,到点发
793
+ * finish,并且**自己把那格写成空气**(digging.js 里的 `bot._updateBlockState(pos, 0)`)。
794
+ * 那一笔假更新触发它自己挂的位置监听,`bot.dig()` 就此 resolve —— 服务端同不同意都
795
+ * 一样。服务端那头是另一本账:它按 tick 自算破坏进度,收到 finish 时进度不到 0.7 就
796
+ * 不破坏,而是把这格挂进延迟破坏自己接着挖。两本账一分家,就是"回执是假的"、"挖了
797
+ * 没掉",以及观察端看到的"一路没有裂纹,然后方块突然掉下来"。
798
+ *
799
+ * 这里把那一笔假更新按下不发(本地世界照真的留着),只补出 digging.js 在等的那个位置
800
+ * 事件让 `bot.dig()` 照常返回,然后自己等服务端真的把那一格改掉。判据是"那格变成了
801
+ * 别的东西",不是"必须变成空气":含水方块挖完留下的是水。
802
+ *
803
+ * 等不到就抛错,**绝不自动重挖**:每发一次 start,服务端的破坏进度就清零一次,重挖周期
804
+ * 短于真实耗时的话那一格永远挖不动。要不要再来一次交给上面决定。
805
+ *
806
+ * 拦得住是因为只有 digging.js 从外面调 `bot._updateBlockState`;blocks.js 处理服务端方块
807
+ * 包走的是模块内的同名闭包,不经过 bot 上这一层。
808
+ */
809
+ function installConfirmedDig(bot: PatchedBot, log: Logger, diag?: MinecraftLog): void {
810
+ if (typeof bot.dig !== 'function' || typeof bot._updateBlockState !== 'function') {
811
+ log.warn('挖掘确认没装上:bot.dig / bot._updateBlockState 还不存在,说明内建插件尚未注入');
812
+ return;
813
+ }
814
+ const origDig = bot.dig.bind(bot) as (b: DigBlock, f?: boolean | 'ignore', d?: unknown) => Promise<void>;
815
+ const origUpdate = bot._updateBlockState.bind(bot);
816
+
817
+ /** 手上这一次挖掘;不在挖时为 null。mineflayer 本身也只允许同时挖一格。 */
818
+ let digging: { key: string; before: { type: number; name: string } | null; confirmed: boolean } | null = null;
819
+
820
+ bot._updateBlockState = (point, stateId): void => {
821
+ if (digging && stateId === 0 && posKey(point) === digging.key) {
822
+ // 世界不动;digging.js 的收尾只看 newBlock.type === 0,补一个空气壳让它收工。
823
+ const emitter = bot.world as unknown as { emit(name: string, ...args: unknown[]): boolean };
824
+ emitter.emit(`blockUpdate:${point}`, bot.blockAt(point), { type: 0, name: 'air', position: point });
825
+ return;
826
+ }
827
+ origUpdate(point, stateId);
828
+ };
829
+
830
+ bot.on('blockUpdate', (_oldBlock, newBlock) => {
831
+ if (!digging || !newBlock?.position) return;
832
+ if (posKey(newBlock.position) !== digging.key) return;
833
+ if (newBlock.type === digging.before?.type) return; // 同一种方块的重发不算确认
834
+ digging.confirmed = true;
835
+ });
836
+
837
+ bot.dig = (async (block: DigBlock, forceLook?: boolean | 'ignore', digFace?: unknown): Promise<void> => {
838
+ const pos = block.position;
839
+ // 非水中悬空时,开挖前最多等 DIG_GROUND_WAIT_MS 毫秒落地,再按当刻姿态计算挖掘时长。
840
+ // 超时仍照常开挖;水下不等待落地,保留水下速度惩罚。
841
+ const airborne = (): boolean => {
842
+ const e = bot.entity as { onGround?: boolean; isInWater?: boolean } | undefined;
843
+ return e?.onGround === false && e.isInWater !== true;
844
+ };
845
+ if (airborne()) {
846
+ const t0 = Date.now();
847
+ while (airborne() && Date.now() - t0 < DIG_GROUND_WAIT_MS) await sleep(20);
848
+ const waited = Date.now() - t0;
849
+ if (waited >= 100) {
850
+ diag?.write({
851
+ lane: 'skill', event: 'dig-ground-wait', durMs: waited,
852
+ msg: `悬空起挖:等了 ${(waited / 1000).toFixed(1)} 秒${airborne() ? '还没落地,照旧开挖' : '落了地'}再算挖掘时长`,
853
+ data: { at: { x: pos.x, y: pos.y, z: pos.z }, landed: !airborne() },
854
+ });
855
+ }
856
+ }
857
+ const before = bot.blockAt(pos);
858
+ const digMs = bot.digTime(block);
859
+ const budget = Number.isFinite(digMs)
860
+ ? Math.max(DIG_CONFIRM_MIN_MS, digMs * DIG_CONFIRM_FACTOR)
861
+ : DIG_CONFIRM_MIN_MS;
862
+ const mine = { key: posKey(pos), before, confirmed: false };
863
+ const at = `(${pos.x}, ${pos.y}, ${pos.z})`;
864
+ const was = before ? zhName(before.name) : '那一格';
865
+ const startedAt = Date.now();
866
+ digging = mine;
867
+ try {
868
+ await origDig(block, forceLook, digFace);
869
+ // mineflayer 认为挖完了的时刻;它与服务端认账的时刻差多少,正是这条链要盯的数
870
+ const localDone = Date.now();
871
+ const deadline = localDone + budget;
872
+ while (!mine.confirmed && !(before && changedAt(bot, pos, before))) {
873
+ if (Date.now() >= deadline) {
874
+ diag?.write({
875
+ lane: 'skill', event: 'dig-unconfirmed', durMs: Date.now() - startedAt,
876
+ msg: `等了 ${(budget / 1000).toFixed(1)} 秒,服务端没把 ${at} 的${was}挖掉`,
877
+ data: { at: { x: pos.x, y: pos.y, z: pos.z }, was: before?.name ?? null, digMs, budgetMs: budget },
878
+ });
879
+ throw new Error(
880
+ `服务端没认这一下:等了 ${(budget / 1000).toFixed(1)} 秒,${at} 还是${was}` +
881
+ '(可能够不着、被保护,或者服务端那头还在自己补挖)',
882
+ );
883
+ }
884
+ await sleep(20);
885
+ }
886
+ const waitedMs = Date.now() - localDone;
887
+ if (waitedMs >= DIG_SLOW_ACK_MS) {
888
+ diag?.write({
889
+ lane: 'skill', event: 'dig-late-ack', durMs: Date.now() - startedAt,
890
+ msg: `${at} 的${was}:本地按 ${(digMs / 1000).toFixed(1)} 秒挖完,服务端又晚了 ${(waitedMs / 1000).toFixed(1)} 秒才认账`,
891
+ data: { at: { x: pos.x, y: pos.y, z: pos.z }, was: before?.name ?? null, digMs, waitedMs },
892
+ });
893
+ }
894
+ } finally {
895
+ if (digging === mine) digging = null;
896
+ }
897
+ }) as unknown as Bot['dig'];
898
+ }
899
+
900
+ /**
901
+ * stopDigging 返回后,若寻路器仍认为在挖且没有 targetDigBlock,补调用中止监听。
902
+ * 此状态可出现在寻路器等待 equip 时;上游的空目标返回不会清除 digging 闩锁。
903
+ * 仅调用零参数监听器:其他监听器需要真实方块来更新挖掘退避账。
904
+ */
905
+ function installDiggingLatchRelease(bot: Bot, diag?: MinecraftLog): void {
906
+ type Digger = (...args: unknown[]) => unknown;
907
+ const noop = ((): void => undefined) as Digger;
908
+ let impl: Digger = typeof bot.stopDigging === 'function' ? (bot.stopDigging as Digger) : noop;
909
+ let released = 0;
910
+ const wrapped = (...args: unknown[]): unknown => {
911
+ const had = bot.targetDigBlock ?? null;
912
+ const out = impl.apply(bot, args);
913
+ if (had !== null) return out; // 真中止过:事件由 digging.js 自己发,不重复
914
+ if (bot.pathfinder?.isMining?.() !== true) return out;
915
+ const listeners = (bot as unknown as { listeners(name: string): unknown[] })
916
+ .listeners('diggingAborted') as Digger[];
917
+ const blind = listeners.filter((fn) => typeof fn === 'function' && fn.length === 0);
918
+ for (const fn of blind) fn.call(bot);
919
+ released += 1;
920
+ diag?.write({
921
+ lane: 'path', event: 'dig-latch-released',
922
+ msg: '寻路器自认在挖、身上却没有挖掘目标(equip 窗口里被 resetPath 撞上):' +
923
+ `已补一记中止解开闩锁,${blind.length === 0 ? '但没有监听器接' : '否则会一直零位移'}`,
924
+ data: { listeners: blind.length, releasedSoFar: released },
925
+ });
926
+ return out;
927
+ };
928
+ // `bot.stopDigging` 每开一次 `dig()` 就被换成那一次的闭包、abort 之后又换回 noop
929
+ // (digging.js:165/188/260),所以只能包在存取器上,包一次函数会被下一次挖掘顶掉。
930
+ Object.defineProperty(bot, 'stopDigging', {
931
+ configurable: true,
932
+ enumerable: true,
933
+ get: () => wrapped,
934
+ set: (fn: Digger) => { if (typeof fn === 'function') impl = fn; },
935
+ });
936
+ }
937
+
938
+ /** 方块位置的比较键;写入与事件来自不同来源,只认整数坐标 */
939
+ function posKey(p: { x: number; y: number; z: number }): string {
940
+ return `${Math.floor(p.x)},${Math.floor(p.y)},${Math.floor(p.z)}`;
941
+ }
942
+
943
+ /**
944
+ * 合成产物仅取服务端更新。每种材料放置完后归还余量,材料切换时游标必须为空。
945
+ */
946
+ function installConfirmedCraft(
947
+ bot: Bot,
948
+ diag: MinecraftLog | undefined,
949
+ trace: (event: string, msg: string, data?: Record<string, unknown>) => void,
950
+ setTracing: (delta: number) => void,
951
+ showTempo?: () => ShowTempo | null,
952
+ ): void {
953
+ bot.craft = async (recipe, count, craftingTable): Promise<void> => {
954
+ const r = recipe as unknown as CraftRecipe;
955
+ const times = Math.max(1, Number(count ?? 1));
956
+ if (r.requiresTable && !craftingTable) {
957
+ throw new Error('Recipe requires craftingTable, but one was not supplied');
958
+ }
959
+ // 开始前关闭遗留窗口;clickWindow 始终以 bot.currentWindow 为目标。
960
+ if (bot.currentWindow) bot.closeWindow(bot.currentWindow);
961
+ setTracing(1);
962
+ let opened: WindowLike | null = null;
963
+ // 演出节拍:只有工作台窗口在摄像机上看得见(窗口 0 的徒手合成协议上没有开窗,
964
+ // 白等没人看);预算由这一整次 bot.craft 共享,times 大时后面的轮次自动恢复瞬时
965
+ const show = new ShowPacer(r.requiresTable ? showTempo?.() ?? null : null);
966
+ const startedAt = Date.now();
967
+ try {
968
+ for (let i = 0; i < times; i++) {
969
+ let window: WindowLike;
970
+ if (r.requiresTable) {
971
+ const firstOpen = opened === null;
972
+ // 上一次关窗离现在太近就先空开:同步屏那边看是一次闪屏(见 show.ts openGap)
973
+ if (firstOpen) await show.openGap();
974
+ opened ??= await openCraftingTable(bot, craftingTable as never);
975
+ window = opened;
976
+ if (firstOpen) await show.beat('open');
977
+ } else {
978
+ window = bot.inventory as unknown as WindowLike;
979
+ }
980
+ await craftOnce(bot, window, r, trace, show);
981
+ }
982
+ diag?.write({
983
+ lane: 'craft', event: 'confirmed', durMs: Date.now() - startedAt,
984
+ msg: `按服务端给的产物做完了 ${times} 次` +
985
+ (r.result.id === null ? '(她自己摆的格子)' : `(物品 #${r.result.id})`),
986
+ data: { item: r.result.id, times, table: r.requiresTable },
987
+ });
988
+ } finally {
989
+ // 工作台窗口一关,格子里的材料由服务端退回背包;窗口 0 关不掉,只能自己拿回来,
990
+ // 否则失败时摆进去的材料会一直不在背包清单里。腾空失败不能盖掉合成本身的错。
991
+ if (opened) {
992
+ await show.beat('close');
993
+ bot.closeWindow(opened as never);
994
+ } else {
995
+ await clearGrid(bot, bot.inventory as unknown as WindowLike, trace).catch(() => undefined);
996
+ }
997
+ setTracing(-1);
998
+ }
999
+ };
1000
+ }
1001
+
1002
+ async function openCraftingTable(bot: Bot, block: never): Promise<WindowLike> {
1003
+ const window = await new Promise<WindowLike | null>((resolve) => {
1004
+ const timer = setTimeout(() => {
1005
+ bot.removeListener('windowOpen', onOpen);
1006
+ resolve(null);
1007
+ }, WINDOW_OPEN_MS);
1008
+ function onOpen(w: unknown): void {
1009
+ clearTimeout(timer);
1010
+ resolve(w as WindowLike);
1011
+ }
1012
+ bot.once('windowOpen', onOpen);
1013
+ void Promise.resolve(bot.activateBlock(block)).catch(() => undefined);
1014
+ });
1015
+ if (!window) throw new Error('右键了工作台,窗口没开出来');
1016
+ if (!window.type.startsWith('minecraft:crafting')) {
1017
+ throw new Error(`开出来的不是工作台窗口(${window.type})`);
1018
+ }
1019
+ return window;
1020
+ }
1021
+
1022
+ /** 徒手是 2x2,工作台是 3x3 */
1023
+ function gridWidth(window: WindowLike): number {
1024
+ return window.type === 'minecraft:inventory' ? 2 : 3;
1025
+ }
1026
+
1027
+ function itemLabel(bot: Bot, id: number): string {
1028
+ const name = (bot.registry.items as Record<number, { name?: string } | undefined>)[id]?.name;
1029
+ return name ? zhName(name) : `物品 #${id}`;
1030
+ }
1031
+
1032
+ /** 配方 → 每个格子该放什么。格子号 1 起,产出槽是 0 */
1033
+ function gridPlan(window: WindowLike, recipe: CraftRecipe): Map<number, number> {
1034
+ const width = gridWidth(window);
1035
+ const plan = new Map<number, number>();
1036
+ if (recipe.inShape) {
1037
+ for (let y = 0; y < recipe.inShape.length; y++) {
1038
+ const row = recipe.inShape[y];
1039
+ for (let x = 0; x < row.length; x++) {
1040
+ const cell = row[x];
1041
+ if (!cell || cell.id === -1) continue;
1042
+ plan.set(1 + x + width * y, cell.id);
1043
+ }
1044
+ }
1045
+ } else if (recipe.ingredients) {
1046
+ let slot = 1;
1047
+ for (const ing of recipe.ingredients) plan.set(slot++, ing.id);
1048
+ }
1049
+ if (plan.size > width * width) throw new Error('配方摆不进这个合成格');
1050
+ return plan;
1051
+ }
1052
+
1053
+ async function craftOnce(
1054
+ bot: Bot, window: WindowLike, recipe: CraftRecipe,
1055
+ trace: (event: string, msg: string, data?: Record<string, unknown>) => void,
1056
+ show?: ShowPacer,
1057
+ ): Promise<void> {
1058
+ const plan = gridPlan(window, recipe);
1059
+ if (plan.size === 0) throw new Error('这个配方没有材料可摆');
1060
+ await clearGrid(bot, window, trace);
1061
+
1062
+ // 按材料分组,一种一趟
1063
+ const bySource = new Map<number, number[]>();
1064
+ for (const [slot, id] of plan) {
1065
+ const list = bySource.get(id);
1066
+ if (list) list.push(slot);
1067
+ else bySource.set(id, [slot]);
1068
+ }
1069
+
1070
+ for (const [id, destSlots] of bySource) {
1071
+ let from: number | null = null;
1072
+ for (const dest of destSlots) {
1073
+ if (!window.selectedItem || window.selectedItem.type !== id) {
1074
+ await stashCursor(bot, window, from, id);
1075
+ const src = window.findInventoryItem(id, null, false);
1076
+ if (!src) throw new Error(`包里没有可用的${itemLabel(bot, id)}`);
1077
+ from = src.slot;
1078
+ await click(bot, src.slot, 0, 0); // 左键拿起整摞
1079
+ }
1080
+ await click(bot, dest, 1, 0); // 右键放一个进格子
1081
+ await show?.beat('click');
1082
+ }
1083
+ await stashCursor(bot, window, from, id); // 余下的放回原处,进下一种材料时光标是空的
1084
+ }
1085
+
1086
+ const result = await waitForResult(window, recipe.result.id, RESULT_WAIT_MS);
1087
+ trace('result-slot', result
1088
+ ? `产出槽出现了物品 #${result.type}×${result.count}(服务端给的)`
1089
+ : `等了 ${RESULT_WAIT_MS}ms 产出槽还是空的`, { want: recipe.result.id, got: result?.type ?? null });
1090
+ if (!result) {
1091
+ // 材料已经摆进格子了,调用方的 finally 负责腾空
1092
+ const per = new Map<number, number>();
1093
+ for (const id of plan.values()) per.set(id, (per.get(id) ?? 0) + 1);
1094
+ const laid = [...per].map(([id, n]) => `${itemLabel(bot, id)}×${n}`).join('、');
1095
+ throw new Error(`材料摆上去了(${laid}),等了 ${RESULT_WAIT_MS}ms 服务端没有给出产物`);
1096
+ }
1097
+
1098
+ await show?.beat('result'); // 产出槽在摄像机画面上亮一拍再收
1099
+ await click(bot, 0, 0, 0); // 拿起真产物;不伪造,changedSlots 才和服务端对得上
1100
+ if (!window.selectedItem) throw new Error('产物槽点了,产物没到手上');
1101
+
1102
+ const merge = window.findItemRange(
1103
+ window.inventoryStart, window.inventoryEnd, result.type, result.metadata, true, result.nbt,
1104
+ );
1105
+ const dest = merge ? merge.slot : window.firstEmptySlotRange(window.inventoryStart, window.inventoryEnd);
1106
+ if (dest === null || dest === undefined) throw new Error('包满了,产物没地方放');
1107
+ await click(bot, dest, 0, 0);
1108
+ }
1109
+
1110
+ /**
1111
+ * 点一下就往下走。
1112
+ *
1113
+ * `bot.clickWindow` 对合成格的点击会去等 `updateSlot:0`,**那个等待没有超时**——
1114
+ * 配方在服务端没配上时它就永远挂着。原版客户端发点击本来也不等回话,这里发出去
1115
+ * 之后最多等 CLICK_ACK_MS 就继续。
1116
+ */
1117
+ async function click(bot: Bot, slot: number, mouseButton: number, mode: number): Promise<void> {
1118
+ const pending = bot.clickWindow(slot, mouseButton, mode);
1119
+ pending.catch(() => undefined); // 被放弃的那次不能变成未处理拒绝
1120
+ await Promise.race([pending, sleep(CLICK_ACK_MS)]);
1121
+ }
1122
+
1123
+ /**
1124
+ * 将光标物品放回来源格或背包空格,防止后续点击按交换物品处理;无处可放时报错。
1125
+ * 仅当光标材质符合 expectedType 时可回来源格,避免回灌换位后混入其他材料。
1126
+ */
1127
+ async function stashCursor(
1128
+ bot: Bot, window: WindowLike, preferred: number | null, expectedType?: number,
1129
+ ): Promise<void> {
1130
+ if (!window.selectedItem) return;
1131
+ const backOk = expectedType === undefined || window.selectedItem.type === expectedType;
1132
+ if (preferred !== null && backOk && !window.slots[preferred]) {
1133
+ await click(bot, preferred, 0, 0);
1134
+ if (!window.selectedItem) return;
1135
+ }
1136
+ const empty = window.firstEmptySlotRange(window.inventoryStart, window.inventoryEnd);
1137
+ if (empty !== null && empty !== undefined) {
1138
+ await click(bot, empty, 0, 0);
1139
+ if (!window.selectedItem) return;
1140
+ }
1141
+ const held = window.selectedItem;
1142
+ throw new Error(`包满了,手上还攥着${itemLabel(bot, held.type)}×${held.count},放不回背包`);
1143
+ }
1144
+
1145
+ /**
1146
+ * 开工前腾空合成格。
1147
+ *
1148
+ * 窗口 0 从不关闭,上一次合成剩在 2x2 格子里的材料会一直留着,连重连都留着。往占着的
1149
+ * 格子右键放料只在本地换位,服务端不动 —— 服务端格子里始终是旧材料,产出槽永远不出货,
1150
+ * 而客户端认为整摞材料进了格子,`findInventoryItem` 从此报"包里没有可用的 X"。
1151
+ */
1152
+ async function clearGrid(
1153
+ bot: Bot, window: WindowLike,
1154
+ trace: (event: string, msg: string, data?: Record<string, unknown>) => void,
1155
+ ): Promise<void> {
1156
+ const width = gridWidth(window);
1157
+ for (let slot = 1; slot <= width * width; slot++) {
1158
+ const left = window.slots[slot];
1159
+ if (!left) continue;
1160
+ await stashCursor(bot, window, null);
1161
+ await click(bot, slot, 0, 0);
1162
+ await stashCursor(bot, window, null);
1163
+ trace('grid-clear', `合成格 ${slot} 里还剩着上次的物品 #${left.type}×${left.count},先拿出来`, {
1164
+ slot, item: left.type, count: left.count,
1165
+ });
1166
+ }
1167
+ }
1168
+
1169
+ /** `itemId === null` = 收任意产物:她自己摆的格子,产出什么由服务端说了算 */
1170
+ async function waitForResult(
1171
+ window: WindowLike, itemId: number | null, ms: number,
1172
+ ): Promise<WindowLike['slots'][number]> {
1173
+ const deadline = Date.now() + ms;
1174
+ for (;;) {
1175
+ const item = window.slots[0];
1176
+ if (item && (itemId === null || item.type === itemId)) return item;
1177
+ if (Date.now() >= deadline) return null;
1178
+ await sleep(25);
1179
+ }
1180
+ }