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,1735 @@
1
+ /**
2
+ * 世界快照与文本渲染(纯逻辑)。
3
+ *
4
+ * `WorldSnapshot` 是从 mineflayer bot 抽出的平面数据。抽取(`snapshotFromBot`)
5
+ * 刻意薄:所有判断(什么算显著、怎么措辞)都在纯函数侧,可直接单测。
6
+ *
7
+ * 渲染出口是 `narrateWorld`:一律中文、写成话,进世界快照事件与其余事件正文。
8
+ */
9
+ import { Vec3 } from 'vec3';
10
+ import { nowIso } from '../../core/util.ts';
11
+ import {
12
+ type Durability, type ItemEnchant, type ItemLike, readDurability, readEnchants,
13
+ } from './item-facts.ts';
14
+ import {
15
+ roman, VILLAGER_PROFESSION_ZH, zhBiome, zhDimension, zhEffect, zhEnchant, zhEntity, zhName,
16
+ } from './names.ts';
17
+ import { piglinIsHostile } from './piglin.ts';
18
+
19
+ /** 八方位罗盘。北=-z 南=+z 东=+x 西=-x,与 move 技能同一套词。 */
20
+ export type Direction =
21
+ | 'north' | 'south' | 'east' | 'west'
22
+ | 'northeast' | 'northwest' | 'southeast' | 'southwest';
23
+
24
+ export const DIRECTIONS: Record<Direction, [number, number]> = {
25
+ north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0],
26
+ northeast: [1, -1], northwest: [-1, -1], southeast: [1, 1], southwest: [-1, 1],
27
+ };
28
+
29
+ export const DIRECTION_ZH: Record<Direction, string> = {
30
+ north: '北', south: '南', east: '东', west: '西',
31
+ northeast: '东北', northwest: '西北', southeast: '东南', southwest: '西南',
32
+ };
33
+
34
+ const COMPASS: Direction[] = [
35
+ 'south', 'southwest', 'west', 'northwest', 'north', 'northeast', 'east', 'southeast',
36
+ ];
37
+
38
+ /** XZ 位移 → 八方位。原地(dx=dz=0)返回 null。 */
39
+ export function bearing(dx: number, dz: number): Direction | null {
40
+ if (Math.abs(dx) < 1e-6 && Math.abs(dz) < 1e-6) return null;
41
+ // atan2(-dx, dz) 让 +z(南) 落在 0,顺时针每 45° 一格
42
+ const angle = Math.atan2(-dx, dz) * (180 / Math.PI);
43
+ const idx = Math.round(((angle % 360) + 360) % 360 / 45) % 8;
44
+ return COMPASS[idx];
45
+ }
46
+
47
+ /** 视线偏航角(mineflayer 的 yaw) → 八方位 */
48
+ export function facingOf(yaw: number): Direction {
49
+ return bearing(-Math.sin(yaw), Math.cos(yaw)) ?? 'south';
50
+ }
51
+
52
+ /**
53
+ * 视线偏航角 → 罗盘度数,北=0 东=90 南=180 西=270。
54
+ * 八方位只有 45° 的分辨率;转头 20° 画面就换了一半,度数才对得上。
55
+ */
56
+ export function facingDegrees(yaw: number): number {
57
+ const deg = (Math.atan2(-Math.sin(yaw), -Math.cos(yaw)) * 180) / Math.PI;
58
+ return Math.round(((deg % 360) + 360) % 360);
59
+ }
60
+
61
+ /** 俯仰(mineflayer 的 pitch,弧度,正数是低头) → 一句话;平视返回 null */
62
+ export function pitchPhrase(pitch: number): string | null {
63
+ const deg = (pitch * 180) / Math.PI;
64
+ if (deg > 45) return '几乎盯着脚下';
65
+ if (deg > 20) return '低头看着地面';
66
+ if (deg < -45) return '仰头看天';
67
+ if (deg < -20) return '抬头往上看';
68
+ return null;
69
+ }
70
+
71
+ /** 运动状态:由速度与落地判断,不猜 */
72
+ type Motion = 'still' | 'walking' | 'sprinting' | 'swimming' | 'falling' | 'rising';
73
+
74
+ interface EntityInfo {
75
+ name: string;
76
+ kind: 'player' | 'hostile' | 'animal' | 'other';
77
+ distance: number;
78
+ /** 相对我的水平方位;正上下方为 null */
79
+ direction: Direction | null;
80
+ /** 相对我的高度差,四舍五入到格 */
81
+ dy: number;
82
+ /** 主视角射线可及(false = 只听得见动静,渲染时不给精确距离坐标) */
83
+ visible: boolean;
84
+ /** 掉落物实体上读到的物品;没有元数据时缺省 */
85
+ item?: ItemStack;
86
+ /** 一眼可见的身份注(村民职业/小孩);没有就缺省 */
87
+ note?: string;
88
+ }
89
+
90
+ export interface ItemStack {
91
+ name: string;
92
+ count: number;
93
+ /** 这一摞身上的附魔;没附魔的物品不填。附魔件不可堆叠,每件占一格 */
94
+ enchantments?: ItemEnchant[];
95
+ }
96
+
97
+ /** 装备槽。手上那件走 `heldItem`,这里只收穿在身上的与副手 */
98
+ export type GearSlot = 'head' | 'chest' | 'legs' | 'feet' | 'offhand';
99
+
100
+ export interface GearPiece {
101
+ slot: GearSlot;
102
+ name: string;
103
+ /** 不带耐久的物品(南瓜头)、或损耗读不到时为 null */
104
+ durability: Durability | null;
105
+ enchantments: ItemEnchant[];
106
+ }
107
+
108
+ interface StatusEffect {
109
+ /** minecraft-data 的 effect name(驼峰) */
110
+ name: string;
111
+ /** 原版 amplifier + 1,即她看见的那个罗马数字的值 */
112
+ level: number;
113
+ /** 剩余秒数;信标那种一直续的照报当刻读数 */
114
+ seconds: number;
115
+ }
116
+
117
+ export interface WorldSnapshot {
118
+ position: { x: number; y: number; z: number };
119
+ dimension: string;
120
+ health: number;
121
+ food: number;
122
+ oxygen: number;
123
+ /** 头部在水中(氧气只有此时才有意义,元数据在旱地上会残留旧值) */
124
+ inWater: boolean;
125
+ /** 物品栏已从服务器同步到(登录后 window_items 未到之前不能把空栏当空) */
126
+ invSynced: boolean;
127
+ /** 0–24000;13000–23000 为夜 */
128
+ timeOfDay: number;
129
+ /** 抽这份快照时的现实时刻,部署时区的 nowIso 串;与游戏内的 timeOfDay 无关 */
130
+ realTime: string;
131
+ /** 她周身那一小片连通空间的亮度众数,0–15;采不到样时为 null */
132
+ light: number | null;
133
+ raining: boolean;
134
+ biome: string;
135
+ gameMode: string;
136
+ heldItem: string | null;
137
+ inventory: ItemStack[];
138
+ /** 经验条上那个整数;附魔的门槛按它算 */
139
+ xpLevel: number;
140
+ /** 穿在身上的与副手,空槽不占条目 */
141
+ equipment: GearPiece[];
142
+ /** 身上的状态效果,空着就没有条目 */
143
+ effects: StatusEffect[];
144
+ /** 近处实体,按距离升序,已截断 */
145
+ entities: EntityInfo[];
146
+ /** 截断掉了几条:她无从分辨"没报"是没有还是没给,这个数把两者分开 */
147
+ entitiesOmitted: number;
148
+ /** 脚下方块 */
149
+ standingOn: string | null;
150
+ /** 周围可见方块种类摘要(种类 → 最近的那一块;容器可多块);带绝对坐标 */
151
+ nearbyBlocks: Array<{
152
+ name: string; distance: number; direction: Direction | null; dy: number;
153
+ x: number; y: number; z: number;
154
+ /** 容器账本:数组=上次看见的内容,null=没开过;非容器不填 */
155
+ contents?: ItemStack[] | null;
156
+ /** 作物龄期:原版 age 方块状态的原值与满龄,不归一化;仅农作物填 */
157
+ age?: { value: number; max: number };
158
+ /** 耕地水分:原版 moisture 方块状态的原值与满值(0–7),不折成布尔;仅耕地填 */
159
+ moisture?: { value: number; max: number };
160
+ }>;
161
+ players: string[];
162
+ /** 8 向 8 格外的地形起伏采样;平地和未加载方向不占条目 */
163
+ terrain: TerrainSample[];
164
+ /** 这一份有没有扫过方块与地形;false = 没扫,不是"扫了没有" */
165
+ blocksScanned: boolean;
166
+ /** 正在怎么动 */
167
+ motion: Motion;
168
+ /** 水平速度(格/秒),用来说"走"还是"跑" */
169
+ speed: number;
170
+ /** 视线朝向 */
171
+ facing: Direction;
172
+ /** 正在往哪个方向移动;没动为 null */
173
+ heading: Direction | null;
174
+ onGround: boolean;
175
+ }
176
+
177
+ /** `unloaded` = 那个方向的区块没加载,没读到;与"读到了、是平的"不是一回事 */
178
+ type TerrainKind = 'up' | 'down' | 'water' | 'solid' | 'unloaded';
179
+
180
+ interface TerrainSample {
181
+ direction: Direction;
182
+ /** 那边的落脚面相对我脚下的高差(格);solid/water 时为参考值 */
183
+ dy: number;
184
+ kind: TerrainKind;
185
+ }
186
+
187
+ export function isNight(timeOfDay: number): boolean {
188
+ return timeOfDay >= 13000 && timeOfDay < 23000;
189
+ }
190
+
191
+ /**
192
+ * 现实世界的此刻,报到分。她在直播:游戏里的昼夜与现实几点是两件事,
193
+ * 而"播了多久""是不是该收了"只有后者答得上。
194
+ */
195
+ function realTimePhrase(realTime: string): string {
196
+ const month = Number(realTime.slice(5, 7));
197
+ const day = Number(realTime.slice(8, 10));
198
+ return `现实世界现在是 ${month} 月 ${day} 日 ${realTime.slice(11, 16)}。`;
199
+ }
200
+
201
+ export function timePhrase(timeOfDay: number): string {
202
+ if (timeOfDay < 1000) return '清晨';
203
+ if (timeOfDay < 6000) return '上午';
204
+ if (timeOfDay < 9000) return '正午前后';
205
+ if (timeOfDay < 12000) return '下午';
206
+ if (timeOfDay < 13000) return '黄昏';
207
+ if (timeOfDay < 18000) return '深夜';
208
+ if (timeOfDay < 23000) return '后半夜';
209
+ return '黎明前';
210
+ }
211
+
212
+ function fmt(n: number): string {
213
+ return String(Math.round(n * 10) / 10);
214
+ }
215
+
216
+ /** 天黑之后天光照不亮东西:眼睛看到的亮度要按这个数扣 */
217
+ const NIGHT_SKY_PENALTY = 11;
218
+ /** 众数落在这个数以内才算"全黑":0 是纯黑,1 也已经什么都看不见 */
219
+ const DARK_LIGHT = 1;
220
+
221
+ /**
222
+ * 周身黑不黑。采不到样(null)不算黑:读不到与真的黑分不开时不猜。
223
+ * 快照那句话与照明维持条件的触发用的是同一个判据。
224
+ */
225
+ export function isDark(light: WorldSnapshot['light']): boolean {
226
+ return light !== null && light <= DARK_LIGHT;
227
+ }
228
+
229
+ /**
230
+ * 周身黑不黑,只报这一件事。
231
+ *
232
+ * 不报具体读数:几点几她不用关心,能不能看见才是。亮的时候一个字都不加,
233
+ * 只有她周身那一小片连通空间的亮度众数(见 `sampleLight`)确实压到 ≤1 才说这一句。
234
+ */
235
+ function lightPhrase(light: WorldSnapshot['light']): string {
236
+ return isDark(light) ? '四周一片漆黑,什么都看不见。' : '';
237
+ }
238
+
239
+ function healthPhrase(h: number): string {
240
+ if (h >= 20) return '状态很好';
241
+ if (h >= 15) return '还算精神';
242
+ if (h >= 10) return '受了点伤';
243
+ if (h >= 6) return '伤得不轻';
244
+ return '快撑不住了';
245
+ }
246
+
247
+ function foodPhrase(f: number): string {
248
+ if (f >= 18) return '不饿';
249
+ if (f >= 10) return '有点饿';
250
+ if (f >= 6) return '饿了';
251
+ return '快饿坏了';
252
+ }
253
+
254
+ function motionPhrase(s: WorldSnapshot): string {
255
+ const where = s.heading ? `朝${DIRECTION_ZH[s.heading]}` : '';
256
+ switch (s.motion) {
257
+ case 'still': return '我站着没动';
258
+ case 'walking': return `我正${where}走着`;
259
+ case 'sprinting': return `我正${where}跑着`;
260
+ case 'swimming': return `我正${where}游着`;
261
+ case 'falling': return '我正在往下掉';
262
+ case 'rising': return '我正在往上浮';
263
+ }
264
+ }
265
+
266
+ /** 某个东西在我的哪个方向、多远;高度差明显时点出来 */
267
+ function whereIs(direction: Direction | null, distance: number, dy: number): string {
268
+ const vertical = dy >= 3 ? '上方' : dy <= -3 ? '下方' : '';
269
+ if (!direction) return vertical ? `正${vertical} ${fmt(distance)} 格` : '就在脚边';
270
+ return `${DIRECTION_ZH[direction]}边${vertical} ${fmt(distance)} 格`;
271
+ }
272
+
273
+ /**
274
+ * 快照的一段:语义同类且更新节奏同档的字段聚在一段。
275
+ * `cmp` 是比对键——显示抖动(动物乱走半格、地形采样毛刺)已量化掉,
276
+ * 键不同才算"这段变了";显示文本在判定变了之后用当刻新鲜值。
277
+ */
278
+ interface SnapshotSegment {
279
+ key: 'place' | 'clock' | 'realclock' | 'body' | 'gear' | 'equip' | 'life' | 'structure' | 'terrain' | 'players';
280
+ /** 完整句子;空串=本段当下无内容(目前只有 structure 可能为空) */
281
+ text: string;
282
+ cmp: string;
283
+ }
284
+
285
+ /** 距离量化到 2 格档(比对键用) */
286
+ function distBucket(d: number): number {
287
+ return Math.round(d / 2);
288
+ }
289
+
290
+ /** 高度差量化成三档,与 whereIs 的 ±3 格口径一致 */
291
+ function dyBand(dy: number): string {
292
+ return dy >= 3 ? '高' : dy <= -3 ? '低' : '平';
293
+ }
294
+
295
+ /**
296
+ * 世界快照正文,按段给出(分段去重的数据源)。一律中文,写成话:
297
+ * 在哪、现在什么时辰、身上什么情况、带着什么、周围有什么、地形如何。
298
+ */
299
+ /**
300
+ * 快照实质变化指纹;位置按调用方阈值单独比较,重生点由 World 加入比对键。
301
+ * 血量向上取整、饥饿四舍五入;敌对生物只计可见者数量及最近者的方向、距离档。
302
+ * 掉落物与和平生物不计入,避免持续移动触发快照。
303
+ */
304
+ export function snapshotFingerprint(s: WorldSnapshot): string {
305
+ const hostiles = s.entities.filter((e) => e.kind === 'hostile' && e.visible);
306
+ const nearest = hostiles.length > 0
307
+ ? hostiles.reduce((a, b) => (a.distance <= b.distance ? a : b))
308
+ : null;
309
+ return [
310
+ s.dimension, s.gameMode,
311
+ `hp${Math.ceil(s.health)}`, `fd${Math.round(s.food)}`,
312
+ s.inWater ? `o${Math.round(s.oxygen)}` : 'dry',
313
+ timePhrase(s.timeOfDay), isNight(s.timeOfDay) ? 'night' : 'day',
314
+ s.raining ? 'rain' : 'clear',
315
+ s.light === null ? 'l?' : `l${s.light}`,
316
+ s.invSynced ? 'inv' : 'nosync',
317
+ s.heldItem ?? 'bare',
318
+ s.inventory.map((i) => `${i.name}x${i.count}`).sort().join(','),
319
+ // 穿着什么、身上有什么效果:一件盔甲耗尽消失、中毒开始与抗火到期都不是她下的令,
320
+ // 快照不报她就无从知道。耐久与剩余秒不进(每秒都在动,进了这道闸就等于没有)
321
+ s.equipment.map((p) => `${p.slot}:${p.name}`).sort().join(','),
322
+ s.effects.map((e) => `${e.name}${e.level}`).sort().join(','),
323
+ s.standingOn ?? 'air', s.biome,
324
+ `hostile${hostiles.length}${nearest ? `@${nearest.direction ?? '-'}${distBucket(nearest.distance)}` : ''}`,
325
+ ].join('|');
326
+ }
327
+
328
+ /**
329
+ * `prevBag` 是上一份**真发出去**的快照里的背包;给了就只印与它不同的条目,
330
+ * 不给(全量锚那一拍、还没有基线)就整份印。比对键与它无关,始终按当刻存量算 ——
331
+ * 否则"变了一次之后就不再重发"或"没变却每拍重发"两头都会出。
332
+ */
333
+ export function narrateWorldSegments(
334
+ s: WorldSnapshot,
335
+ near?: string | null,
336
+ prevBag?: ItemStack[] | null,
337
+ ): SnapshotSegment[] {
338
+ const p = s.position;
339
+ const segs: SnapshotSegment[] = [];
340
+
341
+ // 报我脚下那一格的整数格坐标:她写 at/anchors 用的就是这个口径,
342
+ // 中间不该再插一次她自己做的取整(-31.5 向下取整是 -32,四舍五入会给出 -31)
343
+ //
344
+ // `near` 是坐标的相对化(「离「家」82 格」),由持有路标表的 World 算好递进来 ——
345
+ // 路标是她投影进来的暂态语义,不是世界读数,进不了 WorldSnapshot(与重生点、
346
+ // 目标尾行同一个待遇)。没有近处路标时这一段一个字都不占。
347
+ const place =
348
+ `我在${zhBiome(s.biome)},脚下是${s.standingOn ? zhName(s.standingOn) : '空的'},` +
349
+ `站在格 (${Math.floor(p.x)}, ${Math.floor(p.y)}, ${Math.floor(p.z)})${near ? `,${near}` : ''},` +
350
+ `${zhDimension(s.dimension)}。` +
351
+ `${motionPhrase(s)},面朝${DIRECTION_ZH[s.facing]}。`;
352
+ segs.push({ key: 'place', text: place, cmp: place });
353
+
354
+ const night = isNight(s.timeOfDay);
355
+ const rain = s.raining ? ',在下雨' : '';
356
+ const clock = `现在是${timePhrase(s.timeOfDay)}${night ? ',天黑着' : ''}${rain}。`;
357
+ // 时辰、天候、明暗是同一类环境读数;明暗只有"黑/不黑"两态,那句话本身就是比对键
358
+ const lit = lightPhrase(s.light);
359
+ segs.push({
360
+ key: 'clock',
361
+ text: lit ? `${clock}${lit}` : clock,
362
+ cmp: `${clock}|${lit}`,
363
+ });
364
+
365
+ // 自成一段:现实时钟每分钟都在走,与几个游戏小时才翻一次的时辰不同档,
366
+ // 混在一段里会把整段环境读数拖着一起重发
367
+ const real = realTimePhrase(s.realTime);
368
+ segs.push({ key: 'realclock', text: real, cmp: real });
369
+
370
+ const oxygen = s.inWater && s.oxygen < 20 ? `,泡在水里,氧气还剩 ${Math.round(s.oxygen)}/20` : '';
371
+ const body =
372
+ `${healthPhrase(s.health)}(生命 ${Math.ceil(s.health)}/20),` +
373
+ `${foodPhrase(s.food)}(饥饿 ${Math.round(s.food)}/20)${oxygen}。`;
374
+ segs.push({ key: 'body', text: body, cmp: body });
375
+
376
+ const bagFull = s.inventory.length > 0
377
+ ? `包里有:${narrateInventory(s.inventory)}。`
378
+ : '包里什么都没有。';
379
+ let gear: string;
380
+ if (s.invSynced) {
381
+ const held = s.heldItem ? `手里拿着${zhName(s.heldItem)}` : '两手空空';
382
+ gear = `${held}。${prevBag ? narrateBagChange(prevBag, s.inventory) : bagFull}`;
383
+ } else {
384
+ gear = '背包还在从服务器同步,这份清单还没到。';
385
+ }
386
+ segs.push({
387
+ key: 'gear',
388
+ text: gear,
389
+ cmp: s.invSynced ? `${s.heldItem ?? 'bare'}|${bagFull}` : 'nosync',
390
+ });
391
+
392
+ // 装备与状态自成一段:背包每挖一块土就变,盔甲与效果几十分钟才动一次,
393
+ // 混进 gear 段等于四个槽位每拍重印一遍
394
+ segs.push({ key: 'equip', text: narrateEquip(s), cmp: equipCmp(s) });
395
+
396
+ const seen = s.entities.filter((e) => e.visible);
397
+ const heard = s.entities.filter((e) => !e.visible);
398
+ const seenMobs = seen.filter((e) => !isDroppedItem(e));
399
+ const seenDrops = seen.filter((e) => isDroppedItem(e));
400
+ const lifeLines: string[] = [];
401
+ if (seenMobs.length > 0) {
402
+ lifeLines.push(
403
+ '看得见的:' +
404
+ seenMobs
405
+ .map((e) => {
406
+ const tag = e.kind === 'hostile' ? '(会打我)' : e.kind === 'player' ? '(玩家)' : e.note ? `(${e.note})` : '';
407
+ return `${whereIs(e.direction, e.distance, e.dy)}有${zhEntity(e.name)}${tag}`;
408
+ })
409
+ .join('、') +
410
+ '。',
411
+ );
412
+ }
413
+ if (s.entitiesOmitted > 0) lifeLines.push(`另有 ${s.entitiesOmitted} 个更远的没列进来。`);
414
+ lifeLines.push(...narrateDrops(seenDrops));
415
+ if (heard.length > 0) {
416
+ // 隔着方块只给方位不给距离坐标:听声辨位有这个精度,没有那个精度
417
+ const sounds = [...new Set(heard.map(
418
+ (e) => `${e.direction ? DIRECTION_ZH[e.direction] + '边' : '附近'}有${zhEntity(e.name)}的动静`,
419
+ ))];
420
+ lifeLines.push(`听得见动静的:${sounds.join('、')}。`);
421
+ }
422
+ const lifeCmp = [
423
+ ...seenMobs.map((e) => `见:${e.name}${e.note ? `(${e.note})` : ''}|${e.direction ?? '脚边'}|${distBucket(e.distance)}|${dyBand(e.dy)}`).sort(),
424
+ ...seenDrops.map((e) =>
425
+ `落:${e.item?.name ?? 'item'}|${e.direction ?? '脚边'}|${distBucket(e.distance)}|${dyBand(e.dy)}|${e.item?.count ?? 1}`,
426
+ ).sort(),
427
+ ...[...new Set(heard.map((e) => `闻:${e.name}|${e.direction ?? '附近'}`))].sort(),
428
+ ].join(';');
429
+ segs.push({ key: 'life', text: lifeLines.join('\n'), cmp: lifeCmp });
430
+
431
+ const structLines: string[] = [];
432
+ // 「根本没扫」与「扫过没看见」必须分开说:把前者说成"没有"就是让她在错误
433
+ // 世界观上做正确推理,那种错不会报错,只会把她带进死路。遮挡那一条不在这里
434
+ // 重复——常驻前缀(ENV_PROMPT.md)每轮都在说
435
+ if (!s.blocksScanned) {
436
+ structLines.push('这一份没扫方块和地形,周围有什么不知道。');
437
+ } else {
438
+ structLines.push(
439
+ s.nearbyBlocks.length > 0
440
+ ? '看得见的:' +
441
+ s.nearbyBlocks
442
+ .map((b) => `${whereIs(b.direction, b.distance, b.dy)}是${zhName(b.name)}${blockSuffix(b)}`)
443
+ .join('、') +
444
+ '。'
445
+ : '16 格内露出来的只有天然地形。',
446
+ );
447
+ }
448
+ const structCmp = s.nearbyBlocks.map((b) =>
449
+ `块:${b.name}|${b.direction ?? '脚边'}|${distBucket(b.distance)}|${dyBand(b.dy)}|${contentsCmp(b.contents)}|${stateCmpOf(b)}`,
450
+ ).sort().join(';');
451
+ segs.push({ key: 'structure', text: structLines.join('\n'), cmp: structCmp });
452
+
453
+ // 地形独立成段,比对键固定为常量,只随全量锚出现,不随近处方块变化重发。
454
+ let terrainText = '';
455
+ if (s.terrain.length > 0) {
456
+ const solids = s.terrain.filter((t) => t.kind === 'solid');
457
+ const unloaded = s.terrain.filter((t) => t.kind === 'unloaded');
458
+ const rest = s.terrain.filter((t) => t.kind !== 'solid' && t.kind !== 'unloaded');
459
+ const seg: string[] = [];
460
+ // 地下常态是八向全实心,逐向罗列没有信息量
461
+ if (solids.length >= 6) seg.push('四面八方基本都是实心山体');
462
+ else seg.push(...solids.map((t) => `${DIRECTION_ZH[t.direction]}边是实心山体`));
463
+ seg.push(...rest.map((t) => {
464
+ const d = DIRECTION_ZH[t.direction];
465
+ switch (t.kind) {
466
+ case 'up': return `${d}边高出约 ${t.dy} 格`;
467
+ case 'down': return t.dy <= -(TERRAIN_DOWN + 1) ? `${d}边是深沟或悬崖` : `${d}边低 ${-t.dy} 格`;
468
+ case 'water': return `${d}边是水面`;
469
+ default: return undefined;
470
+ }
471
+ }).filter((x): x is string => x !== undefined));
472
+ // 采到了、是平的 ≠ 根本没采到。八向都有条目时两者都不存在
473
+ const tails: string[] = [];
474
+ if (unloaded.length > 0) {
475
+ tails.push(`${unloaded.map((t) => DIRECTION_ZH[t.direction]).join('、')}边区块没加载,没读到`);
476
+ }
477
+ const covered = new Set(s.terrain.map((t) => t.direction));
478
+ const flat = (Object.keys(DIRECTIONS) as Direction[]).filter((d) => !covered.has(d));
479
+ if (flat.length > 0) tails.push(`${flat.map((d) => DIRECTION_ZH[d]).join('、')}边读到了,是平的`);
480
+ terrainText = `地形:${seg.join('、')}${tails.length > 0 ? `;${tails.join(';')}` : ''}。`;
481
+ }
482
+ segs.push({ key: 'terrain', text: terrainText, cmp: '地形只随全量锚发' });
483
+
484
+ // players 里已经不含我自己(抽快照时连同摄像机一起滤掉了)
485
+ const others = s.players.filter(Boolean);
486
+ segs.push({
487
+ key: 'players',
488
+ text: others.length > 0 ? `服务器上还有:${others.join('、')}。` : '服务器上只有我一个人。',
489
+ cmp: [...others].sort().join('、'),
490
+ });
491
+ return segs;
492
+ }
493
+
494
+ /**
495
+ * 世界快照事件与其余事件正文那一份(全量形态):各段按序拼接。
496
+ */
497
+ export function narrateWorld(s: WorldSnapshot, near?: string | null): string {
498
+ return narrateWorldSegments(s, near).map((g) => g.text).filter(Boolean).join('\n');
499
+ }
500
+
501
+ function isDroppedItem(e: EntityInfo): boolean {
502
+ return e.name === 'item' || e.name === 'Item' || e.name === 'item_stack' || e.item != null;
503
+ }
504
+
505
+ function dropLabel(stack: ItemStack | undefined): string {
506
+ const name = stack?.name && stack.name !== 'item' ? zhName(stack.name) : zhEntity('item');
507
+ return `${name}×${stack?.count ?? 1}`;
508
+ }
509
+
510
+ function dropWhere(e: EntityInfo): string {
511
+ if (!e.direction && Math.abs(e.dy) < 3) return '脚边有掉落物';
512
+ return `${whereIs(e.direction, e.distance, e.dy)}附近有掉落物`;
513
+ }
514
+
515
+ /** 同方位、距离差不多的几堆合成一句;不同方向各写一句 */
516
+ function narrateDrops(drops: EntityInfo[]): string[] {
517
+ if (drops.length === 0) return [];
518
+ const groups = new Map<string, EntityInfo[]>();
519
+ for (const e of drops) {
520
+ const key = `${e.direction ?? '脚边'}|${distBucket(e.distance)}|${dyBand(e.dy)}`;
521
+ const list = groups.get(key);
522
+ if (list) list.push(e);
523
+ else groups.set(key, [e]);
524
+ }
525
+ return [...groups.values()]
526
+ .map((group) => {
527
+ const nearest = group.reduce((a, b) => (a.distance <= b.distance ? a : b));
528
+ const merged = new Map<string, number>();
529
+ for (const e of group) {
530
+ const name = e.item?.name ?? 'item';
531
+ merged.set(name, (merged.get(name) ?? 0) + (e.item?.count ?? 1));
532
+ }
533
+ const list = [...merged.entries()]
534
+ .sort((a, b) => b[1] - a[1])
535
+ .map(([name, count]) => dropLabel({ name, count }))
536
+ .join('、');
537
+ return { d: nearest.distance, text: `${dropWhere(nearest)}:${list}。` };
538
+ })
539
+ .sort((a, b) => a.d - b.d)
540
+ .map((g) => g.text);
541
+ }
542
+
543
+ /**
544
+ * 近处方块的括注:作物龄期 / 耕地水分 / 容器账本,互斥。
545
+ *
546
+ * 作物报原版的 age 方块状态本身(`age 6/7`),不换算成自造的量纲:模型知道
547
+ * 小麦 age 6 差一档就熟,不知道"约9成熟"是什么,中间那次翻译只能由它自己编。
548
+ */
549
+ function blockSuffix(b: WorldSnapshot['nearbyBlocks'][number]): string {
550
+ if (b.age) return `(age ${b.age.value}/${b.age.max})`;
551
+ if (b.moisture) return `(moisture ${b.moisture.value}/${b.moisture.max})`;
552
+ return containerSuffix(b.contents);
553
+ }
554
+
555
+ /**
556
+ * 作物 age 以括注为比对键,只在服务端随机刻推进。
557
+ * moisture 显示原值,比对键只认 > 0 是否水合:四格内有水时置 7,无水时逐随机刻
558
+ * 递减;降到 0 且无作物时耕地退回泥土,生长判据取水合与否。
559
+ */
560
+ function stateCmpOf(b: WorldSnapshot['nearbyBlocks'][number]): string {
561
+ if (b.age) return blockSuffix(b);
562
+ if (b.moisture) return b.moisture.value > 0 ? '湿' : '干';
563
+ return '';
564
+ }
565
+
566
+ function containerSuffix(contents: ItemStack[] | null | undefined): string {
567
+ if (contents === undefined) return '';
568
+ if (contents === null) return '(没开过)';
569
+ if (contents.length === 0) return '(空的)';
570
+ return `(${narrateInventory(contents)})`;
571
+ }
572
+
573
+ function contentsCmp(contents: ItemStack[] | null | undefined): string {
574
+ if (contents === undefined) return '';
575
+ if (contents === null) return '未开';
576
+ return contents.map((i) => `${i.name}×${i.count}`).sort().join(',');
577
+ }
578
+
579
+ /** 附魔括号:`(效率IV·耐久III)`;没附魔返回空串 */
580
+ export function enchantSuffix(enchants: readonly ItemEnchant[] | undefined): string {
581
+ if (!enchants || enchants.length === 0) return '';
582
+ return `(${enchants.map((e) => `${zhEnchant(e.name)}${roman(e.level)}`).join('·')})`;
583
+ }
584
+
585
+ const GEAR_SLOT_ZH: Record<GearSlot, string> = {
586
+ head: '头', chest: '胸', legs: '腿', feet: '脚', offhand: '副手',
587
+ };
588
+ const WORN_ORDER: GearSlot[] = ['head', 'chest', 'legs', 'feet'];
589
+
590
+ function gearPhrase(p: GearPiece): string {
591
+ const dura = p.durability ? ` ${p.durability.left}/${p.durability.max}` : '';
592
+ return `${GEAR_SLOT_ZH[p.slot]}${zhName(p.name)}${enchantSuffix(p.enchantments)}${dura}`;
593
+ }
594
+
595
+ function effectPhrase(e: StatusEffect): string {
596
+ const lvl = e.level > 1 ? roman(e.level) : '';
597
+ return `${zhEffect(e.name)}${lvl}还有 ${e.seconds} 秒`;
598
+ }
599
+
600
+ /** 经验、身上穿的、状态效果。没有效果就不出那一句 */
601
+ function narrateEquip(s: WorldSnapshot): string {
602
+ const worn = WORN_ORDER
603
+ .map((slot) => s.equipment.find((p) => p.slot === slot))
604
+ .filter((p): p is GearPiece => p !== undefined);
605
+ const off = s.equipment.find((p) => p.slot === 'offhand');
606
+ const bits = [`经验 ${s.xpLevel} 级。`];
607
+ bits.push(worn.length > 0 ? `穿着:${worn.map(gearPhrase).join('、')}。` : '身上没穿护甲。');
608
+ if (off) bits.push(`${gearPhrase(off)}。`);
609
+ if (s.effects.length > 0) bits.push(`状态:${s.effects.map(effectPhrase).join('、')}。`);
610
+ return bits.join('');
611
+ }
612
+
613
+ /**
614
+ * 装备段的比对键。耐久按一成一档、效果剩余只在跌破 30 秒时翻 ——
615
+ * 两者都是每秒都在动的读数,进了键这段就每拍重印。
616
+ */
617
+ function equipCmp(s: WorldSnapshot): string {
618
+ const gear = s.equipment
619
+ .map((p) => {
620
+ const wear = p.durability ? Math.round((p.durability.left / p.durability.max) * 10) : '-';
621
+ return `${p.slot}:${p.name}${enchantSuffix(p.enchantments)}:${wear}`;
622
+ })
623
+ .sort()
624
+ .join(',');
625
+ const eff = s.effects
626
+ .map((e) => `${e.name}${e.level}${e.seconds <= 30 ? '!' : ''}`)
627
+ .sort()
628
+ .join(',');
629
+ return `xp${s.xpLevel}|${gear}|${eff}`;
630
+ }
631
+
632
+ /**
633
+ * 物品栏聚合成"圆石×19、泥土×14"的中文列表。
634
+ *
635
+ * 附魔件各自成条并附括号(`钻石镐(效率IV·耐久III)`):原版里带不同附魔的同名物品
636
+ * **不能堆叠**,按名字合并会把三把各不相同的镐念成一样东西。
637
+ */
638
+ export function narrateInventory(items: ItemStack[]): string {
639
+ return listStacks([...mergedStacks(items).values()]);
640
+ }
641
+
642
+ /** 按「名字 + 附魔后缀」并栈:这个键就是原版里能不能堆在一起的那条线 */
643
+ function mergedStacks(items: ItemStack[]): Map<string, { count: number; label: string }> {
644
+ const merged = new Map<string, { count: number; label: string }>();
645
+ for (const it of items) {
646
+ const suffix = enchantSuffix(it.enchantments);
647
+ const key = `${it.name}${suffix}`;
648
+ const cur = merged.get(key);
649
+ if (cur) cur.count += it.count;
650
+ else merged.set(key, { count: it.count, label: `${zhName(it.name)}${suffix}` });
651
+ }
652
+ return merged;
653
+ }
654
+
655
+ function listStacks(v: { count: number; label: string }[]): string {
656
+ return v.sort((a, b) => b.count - a.count).map((s) => `${s.label}×${s.count}`).join('、');
657
+ }
658
+
659
+ /**
660
+ * gear 段只报告相对上一份快照有变化的背包条目,数值为当前存量;消失的物品单列。
661
+ * 进出流水由 inventoryDelta 的 minecraft.world 事件报告。
662
+ */
663
+ function narrateBagChange(prev: ItemStack[], cur: ItemStack[]): string {
664
+ const before = mergedStacks(prev);
665
+ const after = mergedStacks(cur);
666
+ const changed = [...after.entries()].filter(([k, v]) => before.get(k)?.count !== v.count).map(([, v]) => v);
667
+ const gone = [...before.entries()].filter(([k]) => !after.has(k)).map(([, v]) => v.label);
668
+ const parts: string[] = [];
669
+ if (changed.length > 0) parts.push(`包里变的:${listStacks(changed)}`);
670
+ if (gone.length > 0) parts.push(`${gone.join('、')}没了`);
671
+ return parts.length > 0 ? `${parts.join(',')}。` : '';
672
+ }
673
+
674
+ function countByName(items: ItemStack[]): Map<string, number> {
675
+ const merged = new Map<string, number>();
676
+ for (const it of items) merged.set(it.name, (merged.get(it.name) ?? 0) + it.count);
677
+ return merged;
678
+ }
679
+
680
+ /**
681
+ * 物品栏变化独立核对执行器回执,并附带变化后的总数;无变化时返回 null。
682
+ * 总数不受事件迟到或合批影响,可直接判断目标数量是否满足。
683
+ */
684
+ function inventoryDelta(prev: ItemStack[], cur: ItemStack[]): string | null {
685
+ const before = countByName(prev);
686
+ const after = countByName(cur);
687
+ const gained: string[] = [];
688
+ const lost: string[] = [];
689
+ for (const [name, n] of after) {
690
+ const d = n - (before.get(name) ?? 0);
691
+ if (d > 0) gained.push(`${zhName(name)}×${d}(共 ${n})`);
692
+ }
693
+ for (const [name, n] of before) {
694
+ const d = n - (after.get(name) ?? 0);
695
+ if (d > 0) lost.push(`${zhName(name)}×${d}(剩 ${after.get(name) ?? 0})`);
696
+ }
697
+ const parts: string[] = [];
698
+ if (gained.length > 0) parts.push(`包里多了 ${gained.join('、')}`);
699
+ if (lost.length > 0) parts.push(`${gained.length > 0 ? '' : '包里'}少了 ${lost.join('、')}`);
700
+ return parts.length > 0 ? parts.join(',') : null;
701
+ }
702
+
703
+ interface WorldDelta {
704
+ /** 值得说的变化;全空 = 不值得推 minecraft.world 事件。 */
705
+ notes: string[];
706
+ /**
707
+ * 变化只有物品栏增减。采集途中每个节流窗都会多几格土,这类帧单独把她叫醒
708
+ * 只换来一句无话可说,调用方据此选搭车档。
709
+ */
710
+ inventoryOnly: boolean;
711
+ }
712
+
713
+ /**
714
+ * 两个快照之间"值得说"的变化。
715
+ */
716
+ export function worldDelta(
717
+ prev: WorldSnapshot,
718
+ cur: WorldSnapshot,
719
+ moveThreshold: number,
720
+ ): WorldDelta {
721
+ const notes: string[] = [];
722
+ let inventoryNotes = 0;
723
+ const sameDimension = prev.dimension.replace(/^minecraft:/, '') === cur.dimension.replace(/^minecraft:/, '');
724
+ if (sameDimension) {
725
+ const dx = cur.position.x - prev.position.x;
726
+ const dy = cur.position.y - prev.position.y;
727
+ const dz = cur.position.z - prev.position.z;
728
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
729
+ if (dist >= moveThreshold) {
730
+ const dir = bearing(dx, dz);
731
+ const way = dir ? `往${DIRECTION_ZH[dir]}` : '';
732
+ notes.push(
733
+ `${way}走了 ${fmt(dist)} 格,现在在 (${fmt(cur.position.x)}, ${fmt(cur.position.y)}, ${fmt(cur.position.z)})`,
734
+ );
735
+ }
736
+ }
737
+ if (sameDimension && cur.biome !== prev.biome) notes.push(`走进了${zhBiome(cur.biome)}`);
738
+ if (sameDimension && cur.raining !== prev.raining) notes.push(cur.raining ? '开始下雨了' : '雨停了');
739
+
740
+ // 初次同步前的空物品栏不是有效基线。
741
+ if (prev.invSynced && cur.invSynced) {
742
+ const inv = inventoryDelta(prev.inventory, cur.inventory);
743
+ if (inv) {
744
+ notes.push(inv);
745
+ inventoryNotes++;
746
+ }
747
+ }
748
+ // 此处只报告饥饿变化;伤害事件由包含攻击者信息和反射去重的 World 播报处理。
749
+ if (foodPhrase(cur.food) !== foodPhrase(prev.food)) {
750
+ notes.push(`${foodPhrase(cur.food)}(饥饿 ${Math.round(cur.food)}/20)`);
751
+ }
752
+
753
+ // 敌对/友好实体的进出由 World 的接近状态机报告(16 进/24 出滞回),不在摘要里重复。
754
+ return { notes, inventoryOnly: notes.length > 0 && notes.length === inventoryNotes };
755
+ }
756
+
757
+ /** 昼夜转换检测:跨过夜幕(13000)或黎明(23000)返回一句话,否则 null */
758
+ export function dayNightTransition(prevTime: number, curTime: number): string | null {
759
+ const wasNight = isNight(prevTime);
760
+ const nowNight = isNight(curTime);
761
+ if (!wasNight && nowNight) return '夜幕降临了,敌对生物会开始出现。';
762
+ if (wasNight && !nowNight) return '天亮了。';
763
+ return null;
764
+ }
765
+
766
+ const HOSTILE_NAMES = new Set([
767
+ 'zombie', 'skeleton', 'creeper', 'spider', 'cave_spider', 'enderman', 'witch',
768
+ 'slime', 'phantom', 'drowned', 'husk', 'stray', 'pillager', 'vindicator',
769
+ 'ravager', 'vex', 'evoker', 'silverfish', 'zombie_villager', 'guardian',
770
+ 'elder_guardian', 'shulker', 'blaze', 'ghast', 'magma_cube', 'hoglin',
771
+ 'zoglin', 'piglin_brute', 'wither_skeleton', 'warden',
772
+ ]);
773
+
774
+ export function classifyEntity(type: string | undefined, name: string | undefined): EntityInfo['kind'] {
775
+ if (type === 'player') return 'player';
776
+ if (name && HOSTILE_NAMES.has(name)) return 'hostile';
777
+ if (type === 'animal' || type === 'mob' || type === 'water_creature') return 'animal';
778
+ return 'other';
779
+ }
780
+
781
+ /**
782
+ * 快照里不值得列的实体:飞行中/插在地上的射弹。怪物丢的三叉戟规则上捡不起来,
783
+ * 列出来只会引出反复去捡;挨打由受击事件与生命值说,不靠数地上的箭。
784
+ */
785
+ const NOISE_ENTITIES = new Set([
786
+ 'arrow', 'spectral_arrow', 'trident', 'snowball', 'egg', 'ender_pearl',
787
+ 'fireball', 'small_fireball', 'dragon_fireball', 'wither_skull',
788
+ 'potion', 'experience_bottle', 'llama_spit', 'shulker_bullet',
789
+ ]);
790
+
791
+ /** 近处方块播报排除的天然地形与植被集合。 */
792
+ const BG_EXACT = new Set([
793
+ 'air', 'cave_air', 'void_air',
794
+ 'stone', 'cobblestone', 'mossy_cobblestone', 'deepslate', 'cobbled_deepslate',
795
+ 'granite', 'diorite', 'andesite', 'tuff', 'calcite', 'gravel', 'bedrock',
796
+ 'dirt', 'coarse_dirt', 'rooted_dirt', 'grass_block', 'podzol', 'mycelium', 'dirt_path', 'mud',
797
+ 'sand', 'red_sand', 'sandstone', 'red_sandstone', 'smooth_sandstone', 'smooth_red_sandstone',
798
+ 'clay', 'snow', 'snow_block',
799
+ 'netherrack', 'basalt', 'smooth_basalt', 'blackstone', 'end_stone', 'dripstone_block',
800
+ 'terracotta',
801
+ 'short_grass', 'grass', 'tall_grass', 'fern', 'large_fern', 'dead_bush', 'lily_pad',
802
+ 'moss_block', 'moss_carpet', 'hanging_roots', 'glow_lichen', 'sculk_vein', 'sculk',
803
+ 'azalea', 'flowering_azalea', 'big_dripleaf', 'small_dripleaf', 'big_dripleaf_stem',
804
+ 'seagrass', 'tall_seagrass', 'kelp', 'kelp_plant',
805
+ ]);
806
+ const BG_RE = [/_leaves$/, /_terracotta$/, /_sapling$/, /^frosted_ice$/];
807
+
808
+ /** 判成背景也照报:水与岩浆是路况,后四样是碰上就掉血的 */
809
+ const FORCE_REPORT = new Set(['water', 'lava', 'fire', 'magma_block', 'powder_snow', 'cobweb']);
810
+
811
+ /** 天然背景 = 永不进"看得见的"。其余一律报。 */
812
+ export function isBackground(name: string): boolean {
813
+ // 竹笋同时命中 /_sapling$/,但它是伸手就变成物品的可采集物,不是背景;
814
+ // registry 全部 1060 个方块名里这是唯一一处例外
815
+ if (name === 'bamboo_sapling') return false;
816
+ return BG_EXACT.has(name) || BG_RE.some((re) => re.test(name));
817
+ }
818
+
819
+ /**
820
+ * 该报的方块 id 全集。按 registry 记忆化:表是判据算出来的常量,
821
+ * 每帧重算等于把 1060 次正则匹配摊进每份快照。
822
+ * 不含水与岩浆——它们半径 16 内动辄上千格,混进全局调用会把 count 淹掉,各自单扫。
823
+ */
824
+ const reportableCache = new WeakMap<object, Set<number>>();
825
+ function reportableIds(blocksByName: Record<string, { id: number }>): Set<number> {
826
+ const hit = reportableCache.get(blocksByName);
827
+ if (hit) return hit;
828
+ const ids = new Set<number>();
829
+ for (const [name, def] of Object.entries(blocksByName)) {
830
+ if (name === 'water' || name === 'lava') continue;
831
+ if (FORCE_REPORT.has(name) || !isBackground(name)) ids.add(def.id);
832
+ }
833
+ reportableCache.set(blocksByName, ids);
834
+ return ids;
835
+ }
836
+
837
+ /** 农作物的满龄:age 达到即可收割。beetroots 只有 0–3。 */
838
+ export const CROP_MAX_AGE: Record<string, number> = { wheat: 7, carrots: 7, potatoes: 7, beetroots: 3 };
839
+
840
+ /** 耕地 moisture 的满值:四格内有水就置 7,没水每次随机刻掉一档 */
841
+ const FARMLAND_MAX_MOISTURE = 7;
842
+
843
+ /**
844
+ * 快照「近处」可并列多块的容器;其余种类仍只报最近看得见的一块。
845
+ * 炉子族也在列:账本里有槽位读数就随行显示(「熔炉(粗铁×8、煤×2)」),
846
+ * 没开过照容器的口径写「没开过」——15:57 她连撞两次右键问炉子里有什么都拿不到答案。
847
+ */
848
+ const CONTAINER_BLOCKS = new Set([
849
+ 'chest', 'trapped_chest', 'barrel', 'ender_chest',
850
+ 'furnace', 'smoker', 'blast_furnace',
851
+ ]);
852
+ const CONTAINER_NEARBY_CAP = 4;
853
+
854
+ /** 头部所在方块是不是水体(含气泡柱与自带水的水草/海带):溺水判定的前提 */
855
+ export const WATER_BLOCKS = new Set(['water', 'bubble_column', 'kelp', 'kelp_plant', 'seagrass', 'tall_seagrass']);
856
+
857
+ /** 感知半径(格):看得见的按视线算,看不见的按听觉算,这个距离外一律不存在于感知里 */
858
+ const AUDIBLE_RANGE = 16;
859
+
860
+ /**
861
+ * `classifyEntity` 判成 animal、原版里却不发闲置音的水生动物:看不见时连"动静"都没有。
862
+ *
863
+ * 只列水生这一类,是因为其余"看不见就不该报"的实体已经被 `kind === 'other'` 那半条
864
+ * 判据挡住了:mineflayer 的 `entity.type` 直接取 minecraft-data 的 `type`
865
+ * (entities.js:163),展示框/发光展示框/画是 `other`、盔甲架是 `living`,三者都不在
866
+ * `classifyEntity` 认的 animal/mob/water_creature 里,落到 `other`。
867
+ */
868
+ const SILENT_ENTITIES = new Set([
869
+ 'squid', 'glow_squid', 'salmon', 'cod', 'tropical_fish', 'pufferfish', 'tadpole',
870
+ ]);
871
+
872
+ /**
873
+ * 视线射线允许穿过各色普通玻璃、玻璃板与铁栏杆的碰撞箱。
874
+ * tinted_glass、树叶和门不在豁免集内。
875
+ */
876
+ const SEE_THROUGH_BLOCKS = new Set([
877
+ 'glass', 'glass_pane', 'iron_bars',
878
+ ...['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray',
879
+ 'light_gray', 'cyan', 'purple', 'blue', 'brown', 'green', 'red', 'black']
880
+ .flatMap((c) => [`${c}_stained_glass`, `${c}_stained_glass_pane`]),
881
+ ]);
882
+
883
+ /** 这块方块挡不挡视线;`SEE_THROUGH_BLOCKS` 之外按碰撞形状判。 */
884
+ function blocksSight(name: string | null | undefined): boolean {
885
+ return name != null && !SEE_THROUGH_BLOCKS.has(name);
886
+ }
887
+
888
+ /**
889
+ * 主视角能看见这块方块。感知面(世界快照摘要/find 扫描/collect 选目标)必须
890
+ * 过这一关,findBlocks 只按区块索引查、无视遮挡,直接用等于穿墙。
891
+ * 射线到方块中心,只露一角的方块可能漏判——宁可保守,不开挂。
892
+ *
893
+ * 透光集只做加法:先问 mineflayer 的原判据,它说看得见就看得见;只有它说"被挡"
894
+ * 时才自己补一条射线,追问挡住的是不是玻璃。这样规则只会变得更宽、不会变严,
895
+ * 也不必替换那条久经考验的判据。
896
+ */
897
+ export function canSeeBlockAt(bot: any, p: { x: number; y: number; z: number }): boolean {
898
+ const block = bot.blockAt(p as never);
899
+ if (block == null) return false;
900
+ if (bot.canSeeBlock(block) === true) return true;
901
+ const me = bot.entity;
902
+ const ey = me.position.y + (me.eyeHeight ?? 1.62);
903
+ // 眼睛到方块中心,长度和方向用同一条向量算:mineflayer 原版拿"到方块角"的距离
904
+ // 当射程、方向却指向中心,射线会在贴边的目标上差一点点停住。
905
+ const dx = block.position.x + 0.5 - me.position.x;
906
+ const dy = block.position.y + 0.5 - ey;
907
+ const dz = block.position.z + 0.5 - me.position.z;
908
+ const dist = Math.hypot(dx, dy, dz);
909
+ if (dist < 1e-6) return true;
910
+ const isTarget = (q: { x: number; y: number; z: number } | null | undefined): boolean =>
911
+ q != null && q.x === block.position.x && q.y === block.position.y && q.z === block.position.z;
912
+ const match = (hit: any, iter: any): boolean => {
913
+ // 目标自己永远算命中,否则射线从它身上穿过去继续找
914
+ if (isTarget(hit.position)) return true;
915
+ if (!blocksSight(hit.name)) return false;
916
+ return iter.intersect(hit.shapes, hit.position) != null;
917
+ };
918
+ const eye = new Vec3(me.position.x, ey, me.position.z);
919
+ const dir = new Vec3(dx / dist, dy / dist, dz / dist);
920
+ return isTarget(bot.world.raycast(eye, dir, dist, match)?.position);
921
+ }
922
+
923
+ /**
924
+ * 主视角能看见这个实体:眼睛到实体半身高的射线不被方块挡。
925
+ * 水没有碰撞形状,射线穿水——清水里的鱼算看得见。
926
+ */
927
+ export function canSeeEntity(bot: any, e: any): boolean {
928
+ const me = bot.entity;
929
+ // raycast 的迭代器要求真 Vec3(内部调 .minus/.plus),裸 {x,y,z} 会在首次求交时崩
930
+ const eye = new Vec3(me.position.x, me.position.y + (me.height ?? 1.62), me.position.z);
931
+ const tgt = new Vec3(e.position.x, e.position.y + (e.height ?? 1) * 0.5, e.position.z);
932
+ const delta = tgt.minus(eye);
933
+ const dist = delta.norm();
934
+ if (dist < 1) return true;
935
+ // 与方块同一条透光集:隔窗看见村民和隔窗看见箱子是同一件事
936
+ const match = (hit: any, iter: any): boolean =>
937
+ blocksSight(hit?.name) && iter.intersect(hit.shapes, hit.position) != null;
938
+ return bot.world.raycast(eye, delta.scaled(1 / dist), dist, match) === null;
939
+ }
940
+
941
+ /**
942
+ * 村民职业按元数据中 villagerData 的形状识别;幼体位按 1.20.6 的下标 16 读取。
943
+ * 读不到的身份信息不报告,非村民返回 null。
944
+ */
945
+ export function villagerNote(e: { name?: string; metadata?: unknown[] }): string | null {
946
+ if (e?.name !== 'villager') return null;
947
+ const meta = e.metadata;
948
+ if (!Array.isArray(meta)) return null;
949
+ if (meta[16] === true) return '小孩';
950
+ for (const m of meta) {
951
+ if (m && typeof m === 'object' && 'villagerProfession' in (m as Record<string, unknown>)) {
952
+ const id = (m as { villagerProfession: number }).villagerProfession;
953
+ return VILLAGER_PROFESSION_ZH[id] ?? `职业#${id}`;
954
+ }
955
+ }
956
+ return null;
957
+ }
958
+
959
+ /** 地形采样距离(格)与扫描的纵向范围 */
960
+ const TERRAIN_DIST = 8;
961
+ const TERRAIN_UP = 6;
962
+ const TERRAIN_DOWN = 8;
963
+
964
+ /**
965
+ * 8 向 TERRAIN_DIST 格外找落脚面:从上往下扫第一处"上空下实"。
966
+ * |高差|<3 的平缓地和未加载的方向不出条目;整列实心报山体,先见水面报水。
967
+ */
968
+ function sampleTerrain(bot: any, feet: { x: number; y: number; z: number }): TerrainSample[] {
969
+ const out: TerrainSample[] = [];
970
+ const feetY = Math.floor(feet.y);
971
+ for (const direction of Object.keys(DIRECTIONS) as Direction[]) {
972
+ const [dx, dz] = DIRECTIONS[direction];
973
+ const norm = Math.hypot(dx, dz);
974
+ const cx = Math.floor(feet.x + (dx / norm) * TERRAIN_DIST);
975
+ const cz = Math.floor(feet.z + (dz / norm) * TERRAIN_DIST);
976
+ // 初始视为"上方未知":顶格就是实心时不冒认落脚面,整列实心走山体分支
977
+ let prevEmpty = false;
978
+ let sample: TerrainSample | null = null;
979
+ let loaded = true;
980
+ let sawEmpty = false;
981
+ for (let y = feetY + TERRAIN_UP; y >= feetY - TERRAIN_DOWN; y--) {
982
+ const b = bot.blockAt(new Vec3(cx, y, cz));
983
+ if (!b) { loaded = false; break; }
984
+ const empty = b.boundingBox === 'empty';
985
+ if (empty) sawEmpty = true;
986
+ if (prevEmpty && b.name === 'water') {
987
+ sample = { direction, dy: y + 1 - feetY, kind: 'water' };
988
+ break;
989
+ }
990
+ if (prevEmpty && !empty) {
991
+ sample = { direction, dy: y + 1 - feetY, kind: y + 1 - feetY >= 0 ? 'up' : 'down' };
992
+ break;
993
+ }
994
+ prevEmpty = empty;
995
+ }
996
+ if (!loaded) {
997
+ out.push({ direction, dy: 0, kind: 'unloaded' });
998
+ continue;
999
+ }
1000
+ if (!sample) {
1001
+ // 扫描范围内没有落脚面:整列实心 = 山体;整列悬空 = 深沟/悬崖
1002
+ sample = sawEmpty
1003
+ ? { direction, dy: -(TERRAIN_DOWN + 1), kind: 'down' }
1004
+ : { direction, dy: TERRAIN_UP + 1, kind: 'solid' };
1005
+ }
1006
+ if (sample.kind === 'up' || sample.kind === 'down') {
1007
+ if (Math.abs(sample.dy) < 3) continue; // 平缓不占条目
1008
+ }
1009
+ out.push(sample);
1010
+ }
1011
+ return out;
1012
+ }
1013
+
1014
+ /** 采样半径(切比雪夫):她周身这一小片,够判断眼前黑不黑,又不至于把半条隧道算进来 */
1015
+ const LIGHT_RADIUS = 4;
1016
+ /** 采样格数上限:BFS 由近及远,到点就收手(露天时几格之内答案就已经定了) */
1017
+ const LIGHT_CELLS = 96;
1018
+ /** 连通只认六个面——光照就是这么传的,斜着穿墙角不算连通 */
1019
+ const LIGHT_FACES: Array<[number, number, number]> = [
1020
+ [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1],
1021
+ ];
1022
+
1023
+ /**
1024
+ * 她周身那一小片连通空间的亮度众数。
1025
+ *
1026
+ * 从脚和头两格出发按面连通往外漫,只走透光的格(墙挡住的亮不算她看得见的亮);
1027
+ * 每格取"眼睛看到的亮度"= max(方块光, 天光扣夜间惩罚),最后取众数。
1028
+ * 并列取大的那个:宁可漏报黑,也不要再来一次"踩着火把说眼前一片漆黑"。
1029
+ *
1030
+ * 不能只读脚下那一格:不透光方块内部存的光照恒为 0,她卡进方块、或站在耕地
1031
+ * 这类矮碰撞箱上时,脚那一格就是方块自己——读到的 0 是数据对、问题问错。
1032
+ * 采不到样就报 null 不猜:区块没加载时 prismarine 的 world 两路都返回 0,
1033
+ * 跟真的全黑分不开;她整个人被埋住时根本没有可采的空间。
1034
+ */
1035
+ export function sampleLight(bot: any, night: boolean): WorldSnapshot['light'] {
1036
+ const w = bot.world;
1037
+ if (typeof w?.getBlockLight !== 'function' || typeof w.getSkyLight !== 'function') return null;
1038
+ const origin = bot.entity.position.floored();
1039
+ const key = (p: { x: number; y: number; z: number }): string => `${p.x},${p.y},${p.z}`;
1040
+ // 区块没加载时 blockAt 给 null:既走不过去,也不当成空气
1041
+ const open = (p: unknown): boolean => {
1042
+ const b = bot.blockAt(p, false);
1043
+ return Boolean(b) && b.boundingBox !== 'block';
1044
+ };
1045
+ const seen = new Set<string>();
1046
+ const queue: any[] = [];
1047
+ for (const start of [origin, origin.offset(0, 1, 0)]) {
1048
+ if (seen.has(key(start))) continue;
1049
+ seen.add(key(start));
1050
+ if (open(start)) queue.push(start);
1051
+ }
1052
+ const counts = new Map<number, number>();
1053
+ try {
1054
+ let sampled = 0;
1055
+ while (queue.length > 0 && sampled < LIGHT_CELLS) {
1056
+ const p = queue.shift();
1057
+ const block = w.getBlockLight(p);
1058
+ const sky = w.getSkyLight(p);
1059
+ if (!Number.isFinite(block) || !Number.isFinite(sky)) continue;
1060
+ const lit = Math.max(0, Math.min(15, Math.round(
1061
+ Math.max(block, night ? sky - NIGHT_SKY_PENALTY : sky),
1062
+ )));
1063
+ counts.set(lit, (counts.get(lit) ?? 0) + 1);
1064
+ sampled++;
1065
+ for (const [dx, dy, dz] of LIGHT_FACES) {
1066
+ const q = p.offset(dx, dy, dz);
1067
+ const far = Math.max(
1068
+ Math.abs(q.x - origin.x), Math.abs(q.y - origin.y), Math.abs(q.z - origin.z),
1069
+ );
1070
+ if (far > LIGHT_RADIUS || seen.has(key(q))) continue;
1071
+ seen.add(key(q));
1072
+ if (open(q)) queue.push(q);
1073
+ }
1074
+ }
1075
+ } catch {
1076
+ return null;
1077
+ }
1078
+ let mode: number | null = null;
1079
+ let best = 0;
1080
+ for (const [lit, n] of counts) {
1081
+ if (n > best || (n === best && mode !== null && lit > mode)) {
1082
+ mode = lit;
1083
+ best = n;
1084
+ }
1085
+ }
1086
+ return mode;
1087
+ }
1088
+
1089
+ /**
1090
+ * 使用 rainState 判断降雨。Mineflayer 的 isRaining 按 game_state_change reason 1/2
1091
+ * 的常量名更新布尔值,与原版实际行为极性相反。
1092
+ * reason 7 的 rain_level_change 提供 0–1 雨量,存入 rainState;进服和重生时会重发。
1093
+ */
1094
+ export function isRaining(bot: any): boolean {
1095
+ const level = (bot as { rainState?: unknown }).rainState;
1096
+ return typeof level === 'number' && Number.isFinite(level) && level > 0;
1097
+ }
1098
+
1099
+ /** 脚**所在**那一格是这些时,快照的「脚下是X」报它而不是脚下那一格(见 standingOn) */
1100
+ const LIQUID_UNDERFOOT = new Set(['water', 'lava', 'bubble_column']);
1101
+
1102
+ /** 头部(脚上一格)在水中。氧气元数据只有此时可信:上岸后服务器不再推,旧值一直残留。 */
1103
+ export function headInWater(bot: any): boolean {
1104
+ const head = bot.blockAt(bot.entity.position.offset(0, 1, 0));
1105
+ return head != null && WATER_BLOCKS.has(head.name);
1106
+ }
1107
+
1108
+ /** 身子泡在水里(脚所在格或头所在格是水)。水里 onGround 恒假、疾跑无效,战斗姿态与撤退选路都要认它 */
1109
+ export function bodyInWater(bot: any): boolean {
1110
+ const feet = bot.blockAt(bot.entity.position);
1111
+ if (feet != null && WATER_BLOCKS.has(feet.name)) return true;
1112
+ return headInWater(bot);
1113
+ }
1114
+
1115
+ /**
1116
+ * 从水里找登岸格:脚下实心、脚与头都不是水也不是实心。由近及远逐圈扫,同一圈里
1117
+ * 优先背离 `from`(怪群质心)那一侧——对着追兵上岸等于把背身白送给它。
1118
+ */
1119
+ export function findBankCell(
1120
+ bot: any, from: { x: number; z: number } | null, maxR = 16,
1121
+ ): { x: number; y: number; z: number } | null {
1122
+ const me = bot.entity.position;
1123
+ const base = me.floored();
1124
+ const ax = from ? me.x - from.x : 0;
1125
+ const az = from ? me.z - from.z : 0;
1126
+ const alen = Math.hypot(ax, az) || 1;
1127
+ for (let r = 2; r <= maxR; r += 2) {
1128
+ let best: { x: number; y: number; z: number } | null = null;
1129
+ let bestDot = -Infinity;
1130
+ for (let dx = -r; dx <= r; dx += 2) {
1131
+ for (let dz = -r; dz <= r; dz += 2) {
1132
+ if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
1133
+ for (let dy = 2; dy >= -1; dy--) {
1134
+ const x = base.x + dx, y = base.y + dy, z = base.z + dz;
1135
+ const below = bot.blockAt(new Vec3(x, y - 1, z));
1136
+ const feet = bot.blockAt(new Vec3(x, y, z));
1137
+ const head = bot.blockAt(new Vec3(x, y + 1, z));
1138
+ if (!below || !feet || !head) continue;
1139
+ if (below.boundingBox !== 'block') continue;
1140
+ if (feet.boundingBox !== 'empty' || WATER_BLOCKS.has(feet.name)) continue;
1141
+ if (head.boundingBox !== 'empty' || WATER_BLOCKS.has(head.name)) continue;
1142
+ const d = Math.hypot(dx, dz) || 1;
1143
+ const dot = (dx * ax + dz * az) / (d * alen);
1144
+ if (dot > bestDot) { bestDot = dot; best = { x, y, z }; }
1145
+ break;
1146
+ }
1147
+ }
1148
+ }
1149
+ if (best) return best;
1150
+ }
1151
+ return null;
1152
+ }
1153
+
1154
+ /** 碰上就烧人的方块:碰撞箱压上去即掉血(combat 的 standable 与这里共用一份口径) */
1155
+ export const BURNING_BLOCKS = new Set(['lava', 'fire', 'soul_fire']);
1156
+ /** 只有站在上面才烧的:单独查脚下那一格 */
1157
+ export const SCORCHING_FLOOR = new Set(['magma_block']);
1158
+ /** 玩家碰撞箱:半径 0.3、高 1.8。0.1 是余量——贴着岩浆边缘走就已经在挨烧了 */
1159
+ const HITBOX_R = 0.3;
1160
+ const TOUCH_MARGIN = 0.1;
1161
+ const PLAYER_HEIGHT = 1.8;
1162
+ /** 实体共享元数据第 0 字节的第 0 位 = 身上着火 */
1163
+ const FIRE_FLAG = 0x01;
1164
+
1165
+ export interface HazardCell {
1166
+ x: number; y: number; z: number;
1167
+ name: string;
1168
+ /** 到人(脚部坐标)的距离,格 */
1169
+ distance: number;
1170
+ }
1171
+
1172
+ interface HazardTouch {
1173
+ /** 碰撞箱压着的最近一格烧人方块;null = 没碰上 */
1174
+ touching: HazardCell | null;
1175
+ /** 脚那格就是岩浆:人在往下沉,得一直往上游 */
1176
+ submerged: boolean;
1177
+ /** 身上着火;元数据读不到时为 false */
1178
+ onFire: boolean;
1179
+ }
1180
+
1181
+ function cellOf(from: { x: number; y: number; z: number }, x: number, y: number, z: number, name: string): HazardCell {
1182
+ return {
1183
+ x, y, z, name,
1184
+ distance: Math.hypot(from.x - (x + 0.5), from.y - (y + 0.5), from.z - (z + 0.5)),
1185
+ };
1186
+ }
1187
+
1188
+ /**
1189
+ * 按碰撞箱及水平余量检测接触的烧灼方块,供反射心跳读取。
1190
+ * 没有侧面接触时另查脚下的灼热表面。
1191
+ */
1192
+ export function hazardTouch(bot: any): HazardTouch {
1193
+ const p = bot.entity.position;
1194
+ const base = p.floored();
1195
+ const reach = HITBOX_R + TOUCH_MARGIN;
1196
+ // 脚往上抬 0.05 格再取整:站在方块顶面时坐标常是整数,直接 floor 会读到脚下的实心方块
1197
+ const dy0 = Math.floor(p.y + 0.05) - base.y;
1198
+ const dy1 = Math.floor(p.y + PLAYER_HEIGHT - 0.1) - base.y;
1199
+ let touching: HazardCell | null = null;
1200
+ let submerged = false;
1201
+ for (let dx = Math.floor(p.x - reach) - base.x; dx <= Math.floor(p.x + reach) - base.x; dx++) {
1202
+ for (let dz = Math.floor(p.z - reach) - base.z; dz <= Math.floor(p.z + reach) - base.z; dz++) {
1203
+ for (let dy = dy0; dy <= dy1; dy++) {
1204
+ const b = bot.blockAt(base.offset(dx, dy, dz));
1205
+ if (b == null || !BURNING_BLOCKS.has(b.name)) continue;
1206
+ const c = cellOf(p, base.x + dx, base.y + dy, base.z + dz, b.name);
1207
+ if (touching === null || c.distance < touching.distance) touching = c;
1208
+ if (dx === 0 && dz === 0 && dy === dy0 && b.name === 'lava') submerged = true;
1209
+ }
1210
+ }
1211
+ }
1212
+ if (touching === null) {
1213
+ const floor = bot.blockAt(base.offset(0, dy0 - 1, 0));
1214
+ if (floor != null && SCORCHING_FLOOR.has(floor.name)) {
1215
+ touching = cellOf(p, base.x, base.y + dy0 - 1, base.z, floor.name);
1216
+ }
1217
+ }
1218
+ return { touching, submerged, onFire: onFire(bot) };
1219
+ }
1220
+
1221
+ /** 身上着不着火。元数据在部分版本/重连后读不到,读不到就当没着火,判定另有碰撞箱那条路兜底 */
1222
+ function onFire(bot: any): boolean {
1223
+ const flags = bot.entity?.metadata?.[0];
1224
+ return typeof flags === 'number' && (flags & FIRE_FLAG) !== 0;
1225
+ }
1226
+
1227
+ /** 半径内所有烧人的方块,近的在前。只在真挨烧时才扫,不进心跳 */
1228
+ export function hazardsWithin(bot: any, radius: number): HazardCell[] {
1229
+ const p = bot.entity.position;
1230
+ const base = p.floored();
1231
+ const r = Math.ceil(radius);
1232
+ const found: HazardCell[] = [];
1233
+ for (let dx = -r; dx <= r; dx++) {
1234
+ for (let dz = -r; dz <= r; dz++) {
1235
+ for (let dy = -1; dy <= 2; dy++) {
1236
+ const b = bot.blockAt(base.offset(dx, dy, dz));
1237
+ if (b == null || !BURNING_BLOCKS.has(b.name)) continue;
1238
+ const c = cellOf(p, base.x + dx, base.y + dy, base.z + dz, b.name);
1239
+ if (c.distance <= radius) found.push(c);
1240
+ }
1241
+ }
1242
+ }
1243
+ return found.sort((a, b) => a.distance - b.distance);
1244
+ }
1245
+
1246
+ /** 半径内最近的烧人方块 */
1247
+ export function nearestHazard(bot: any, radius: number): HazardCell | null {
1248
+ return hazardsWithin(bot, radius)[0] ?? null;
1249
+ }
1250
+
1251
+ /** 逃生落脚格离每一处危险都至少这么远才算安全 */
1252
+ const ESCAPE_SAFE_GAP = 3;
1253
+ /** 逃生路线的抽样点数:够挡住"落脚点是安全的、可是路上还要蹚一遍岩浆" */
1254
+ const ROUTE_SAMPLES = 4;
1255
+
1256
+ /**
1257
+ * 逃生格距每处危险至少 ESCAPE_SAFE_GAP 格,直线路径须通过危险格采样检查。
1258
+ * 距离评分兼顾远离危险与就近;默认要求脚下实心、脚与头可容身且无火和水。
1259
+ * preferWater 为 true 时优先水格,并允许水格下无实心支撑;找不到返回 null。
1260
+ */
1261
+ export function findEscapeCell(
1262
+ bot: any,
1263
+ hazards: HazardCell[],
1264
+ maxR: number,
1265
+ preferWater = false,
1266
+ ): { x: number; y: number; z: number } | null {
1267
+ if (hazards.length === 0) return null;
1268
+ const p = bot.entity.position;
1269
+ const base = p.floored();
1270
+ const blocked = new Set(hazards.map((h) => `${h.x},${h.y},${h.z}`));
1271
+ let best: { cell: { x: number; y: number; z: number }; score: number } | null = null;
1272
+ for (let dx = -maxR; dx <= maxR; dx++) {
1273
+ for (let dz = -maxR; dz <= maxR; dz++) {
1274
+ for (let dy = -1; dy <= 1; dy++) {
1275
+ const x = base.x + dx, y = base.y + dy, z = base.z + dz;
1276
+ let gap = Infinity;
1277
+ for (const h of hazards) {
1278
+ const d = Math.hypot(x - h.x, y - h.y, z - h.z);
1279
+ if (d < gap) gap = d;
1280
+ }
1281
+ if (gap < ESCAPE_SAFE_GAP) continue;
1282
+ // 打分只用距离,便宜;读块的两道校验留到确定它比现任更好之后再做
1283
+ let score = gap * 2 - Math.hypot(dx, dy, dz);
1284
+ if (preferWater) {
1285
+ // 着火时水格压倒一切距离分;这一读只在着火时发生,不动常规路径的成本
1286
+ const feetB = bot.blockAt(base.offset(dx, dy, dz));
1287
+ if (feetB != null && WATER_BLOCKS.has(feetB.name)) score += WATER_ESCAPE_PRIORITY;
1288
+ }
1289
+ if (best !== null && score <= best.score) continue;
1290
+ if (!routeClear(p, { x, y, z }, blocked)) continue;
1291
+ if (!standableFor(bot, base.offset(dx, dy, dz), preferWater)) continue;
1292
+ best = { cell: { x, y, z }, score };
1293
+ }
1294
+ }
1295
+ }
1296
+ return best?.cell ?? null;
1297
+ }
1298
+
1299
+ /** 着火找水时给水格加的分:要压倒 `gap * 2` 能给出的任何值(gap ≤ 2·maxR ≈ 8) */
1300
+ const WATER_ESCAPE_PRIORITY = 100;
1301
+
1302
+ /** 从人到落脚格的直线上抽几个点,踩到火的路线不要 */
1303
+ function routeClear(
1304
+ from: { x: number; y: number; z: number },
1305
+ to: { x: number; y: number; z: number },
1306
+ blocked: Set<string>,
1307
+ ): boolean {
1308
+ for (let i = 1; i <= ROUTE_SAMPLES; i++) {
1309
+ const t = i / (ROUTE_SAMPLES + 1);
1310
+ const x = Math.floor(from.x + (to.x + 0.5 - from.x) * t);
1311
+ const y = Math.floor(from.y + (to.y - from.y) * t);
1312
+ const z = Math.floor(from.z + (to.z + 0.5 - from.z) * t);
1313
+ if (blocked.has(`${x},${y},${z}`) || blocked.has(`${x},${y + 1},${z}`)) return false;
1314
+ }
1315
+ return true;
1316
+ }
1317
+
1318
+ /**
1319
+ * 逃生落脚格的可站判定:脚下实心,脚与头两格空且不烧人、不是液体。
1320
+ * `preferWater` 时水是资产不是障碍:脚泡进水里火就灭,人会浮,不要求脚下实心。
1321
+ */
1322
+ function standableFor(
1323
+ bot: any,
1324
+ feet: { offset(dx: number, dy: number, dz: number): unknown },
1325
+ preferWater = false,
1326
+ ): boolean {
1327
+ const below = bot.blockAt(feet.offset(0, -1, 0));
1328
+ const at = bot.blockAt(feet.offset(0, 0, 0));
1329
+ const head = bot.blockAt(feet.offset(0, 1, 0));
1330
+ if (below == null || at == null || head == null) return false; // 没加载的格不当逃生点
1331
+ if (preferWater && WATER_BLOCKS.has(at.name)
1332
+ && head.boundingBox !== 'block' && !BURNING_BLOCKS.has(head.name)) {
1333
+ return true;
1334
+ }
1335
+ if (below.boundingBox !== 'block' || SCORCHING_FLOOR.has(below.name)) return false;
1336
+ for (const b of [at, head]) {
1337
+ if (b.boundingBox === 'block') return false;
1338
+ if (BURNING_BLOCKS.has(b.name)) return false;
1339
+ if (!preferWater && WATER_BLOCKS.has(b.name)) return false;
1340
+ }
1341
+ return true;
1342
+ }
1343
+
1344
+ /** 探路分诊用的最小方块读数;null = 区块未加载 */
1345
+ interface ProbeBlockInfo { name: string; solid: boolean }
1346
+ export type BlockReader = (x: number, y: number, z: number) => ProbeBlockInfo | null;
1347
+
1348
+ /**
1349
+ * GoalNear(range=1) 的候选落脚格:目标格自身 + 六个正邻格里,能站人的那些。
1350
+ * 能站 = 脚与头两格都不是实心,且脚下实心(或脚泡在水里,浮着也算到了)。
1351
+ * 一格都没有 = 这个目标在当前地形下根本没处落脚(树冠/墙里的典型形态)——
1352
+ * A* 对这种目标只会以 timeout 收场,报不出这句真话,而预检 O(27) 就能报。
1353
+ * 任何一格读数缺失(未加载)时把那格当能站,宁可不下结论也不误诊。
1354
+ */
1355
+ export function standCellsAround(
1356
+ read: BlockReader,
1357
+ t: { x: number; y: number; z: number },
1358
+ ): Array<{ x: number; y: number; z: number }> {
1359
+ const candidates = [
1360
+ { x: t.x, y: t.y, z: t.z },
1361
+ { x: t.x + 1, y: t.y, z: t.z }, { x: t.x - 1, y: t.y, z: t.z },
1362
+ { x: t.x, y: t.y + 1, z: t.z }, { x: t.x, y: t.y - 1, z: t.z },
1363
+ { x: t.x, y: t.y, z: t.z + 1 }, { x: t.x, y: t.y, z: t.z - 1 },
1364
+ ];
1365
+ const out: Array<{ x: number; y: number; z: number }> = [];
1366
+ for (const c of candidates) {
1367
+ const feet = read(c.x, c.y, c.z);
1368
+ const head = read(c.x, c.y + 1, c.z);
1369
+ const below = read(c.x, c.y - 1, c.z);
1370
+ if (!feet || !head || !below) { out.push(c); continue; }
1371
+ const passable = !feet.solid && !head.solid;
1372
+ const support = below.solid || WATER_BLOCKS.has(feet.name);
1373
+ if (passable && support) out.push(c);
1374
+ }
1375
+ return out;
1376
+ }
1377
+
1378
+ /**
1379
+ * 从落脚格向外灌水(六邻 BFS,只走非实心格):cap 内没摸到边 = 目标被封在小死角,
1380
+ * 返回死角大小;触到 cap 或摸到未加载区块 = 开阔地/下不了结论,返回 null。
1381
+ * 只做连通性不做完整移动规则——1 格缝也算通,宁可漏报"封死"也不误报。
1382
+ */
1383
+ export function pocketScan(
1384
+ read: BlockReader,
1385
+ seeds: Array<{ x: number; y: number; z: number }>,
1386
+ cap = 128,
1387
+ ): number | null {
1388
+ const visited = new Set<string>();
1389
+ const queue: Array<{ x: number; y: number; z: number }> = [];
1390
+ for (const s of seeds) {
1391
+ const key = `${s.x},${s.y},${s.z}`;
1392
+ if (!visited.has(key)) { visited.add(key); queue.push(s); }
1393
+ }
1394
+ while (queue.length > 0) {
1395
+ if (visited.size >= cap) return null;
1396
+ const c = queue.shift()!;
1397
+ for (const [dx, dy, dz] of [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]] as const) {
1398
+ const n = { x: c.x + dx, y: c.y + dy, z: c.z + dz };
1399
+ const key = `${n.x},${n.y},${n.z}`;
1400
+ if (visited.has(key)) continue;
1401
+ const b = read(n.x, n.y, n.z);
1402
+ if (!b) return null;
1403
+ if (b.solid) continue;
1404
+ visited.add(key);
1405
+ queue.push(n);
1406
+ }
1407
+ }
1408
+ return visited.size;
1409
+ }
1410
+
1411
+ /**
1412
+ * 掉落物实体上的物品。优先 `getDroppedItem()`(prismarine-entity 的口径);
1413
+ * 没有元数据时记成不明掉落物,不猜。
1414
+ */
1415
+ export function droppedStackOf(
1416
+ e: {
1417
+ name?: string;
1418
+ getDroppedItem?: () => { name?: string; count?: number; itemId?: number; itemCount?: number } | null;
1419
+ heldItem?: { name?: string; count?: number };
1420
+ item?: { name?: string; count?: number };
1421
+ metadata?: unknown;
1422
+ },
1423
+ items?: Record<number, { name: string }>,
1424
+ ): ItemStack | null {
1425
+ const n = e.name;
1426
+ if (n !== 'item' && n !== 'Item' && n !== 'item_stack') return null;
1427
+ let raw: { name?: string; count?: number; itemId?: number; itemCount?: number } | null = null;
1428
+ if (typeof e.getDroppedItem === 'function') {
1429
+ try { raw = e.getDroppedItem(); } catch { raw = null; }
1430
+ }
1431
+ if (!raw) raw = e.heldItem ?? e.item ?? null;
1432
+ if (!raw && Array.isArray(e.metadata)) {
1433
+ const hit = e.metadata.find((m) =>
1434
+ m && typeof m === 'object' && ('name' in m || 'itemId' in m || 'itemCount' in m),
1435
+ );
1436
+ if (hit && typeof hit === 'object') raw = hit as typeof raw;
1437
+ }
1438
+ if (!raw) return { name: 'item', count: 1 };
1439
+ let name = typeof raw.name === 'string' ? raw.name.replace(/^minecraft:/, '') : '';
1440
+ if (!name && typeof raw.itemId === 'number' && items?.[raw.itemId]) name = items[raw.itemId].name;
1441
+ if (!name) name = 'item';
1442
+ const count = typeof raw.count === 'number' ? raw.count
1443
+ : typeof raw.itemCount === 'number' ? raw.itemCount
1444
+ : 1;
1445
+ return { name, count };
1446
+ }
1447
+
1448
+ /**
1449
+ * 作物 age / 耕地 moisture 读进快照条目。属性值随 prismarine 版本可能是字符串,
1450
+ * 数值化失败(含假实现没有 getProperties)就不填,渲染侧按无括注处理。
1451
+ */
1452
+ function blockState(
1453
+ bot: any,
1454
+ name: string,
1455
+ p: { x: number; y: number; z: number },
1456
+ ): { age?: { value: number; max: number }; moisture?: { value: number; max: number } } {
1457
+ const max = CROP_MAX_AGE[name];
1458
+ if (max === undefined && name !== 'farmland') return {};
1459
+ const b = bot.blockAt(p as never);
1460
+ const props = typeof b?.getProperties === 'function' ? b.getProperties() : undefined;
1461
+ if (name === 'farmland') {
1462
+ const moisture = Number(props?.moisture);
1463
+ return Number.isFinite(moisture) ? { moisture: { value: moisture, max: FARMLAND_MAX_MOISTURE } } : {};
1464
+ }
1465
+ const age = Number(props?.age);
1466
+ return Number.isFinite(age) ? { age: { value: age, max } } : {};
1467
+ }
1468
+
1469
+ /**
1470
+ * 那一格是农作物时读它的 age(原版方块状态原值);不是作物或读不出返回 null。
1471
+ * probe 的命中清单与 collect 的「只收熟的」都用它——同一份读数,两处不各写各的。
1472
+ */
1473
+ export function cropAgeAt(
1474
+ bot: any,
1475
+ p: { x: number; y: number; z: number },
1476
+ ): { value: number; max: number } | null {
1477
+ const b = bot.blockAt(p as never);
1478
+ if (!b) return null;
1479
+ const max = CROP_MAX_AGE[b.name as string];
1480
+ if (max === undefined) return null;
1481
+ const props = typeof b.getProperties === 'function' ? b.getProperties() : undefined;
1482
+ const age = Number(props?.age);
1483
+ return Number.isFinite(age) ? { value: age, max } : null;
1484
+ }
1485
+
1486
+ /**
1487
+ * 感知半径内 age 到顶的农作物。到期事件的观察源:作物的成熟**只能观察,不能倒计时**
1488
+ * ——随机刻是 `randomTickSpeed/4096` 的几何分布再乘生长点数判定,报「按估计几秒后熟」
1489
+ * 就是在编数(熔炉那种确定性 200 刻一件才可以估)。
1490
+ */
1491
+ export function scanMatureCrops(
1492
+ bot: any,
1493
+ maxDistance = 16,
1494
+ ): Array<{ x: number; y: number; z: number; name: string; age: number; max: number }> {
1495
+ const out: Array<{ x: number; y: number; z: number; name: string; age: number; max: number }> = [];
1496
+ for (const [name, max] of Object.entries(CROP_MAX_AGE)) {
1497
+ const id = bot.registry?.blocksByName?.[name]?.id;
1498
+ if (id === undefined) continue;
1499
+ for (const p of bot.findBlocks({ matching: [id], maxDistance, count: 64 })) {
1500
+ const age = cropAgeAt(bot, p);
1501
+ if (age && age.value >= age.max) out.push({ x: p.x, y: p.y, z: p.z, name, age: age.value, max });
1502
+ }
1503
+ }
1504
+ return out;
1505
+ }
1506
+
1507
+ /** 那一格所在的群系名;区块未加载(或注册表缺群系表)时 'unknown'。 */
1508
+ export function biomeAt(bot: any, pos: { x: number; y: number; z: number }): string {
1509
+ try {
1510
+ const b = bot.blockAt(pos as never);
1511
+ const rec = b && bot.registry?.biomes?.[b.biome.id];
1512
+ return rec ? rec.name : 'unknown';
1513
+ } catch {
1514
+ return 'unknown';
1515
+ }
1516
+ }
1517
+
1518
+ /**
1519
+ * Captures a flat Mineflayer snapshot. Inventory data is trusted only after `invSynced`;
1520
+ * `scanBlocks: false` omits the block scan.
1521
+ */
1522
+ /**
1523
+ * 玩家物品栏窗口里的装备槽下标(原版协议固定值):5–8 是头胸腿脚,45 是副手。
1524
+ * `bot.inventory.items()` 只给主物品栏,这五格一格都不在里面。
1525
+ */
1526
+ const GEAR_SLOT_INDEX: ReadonlyArray<[GearSlot, number]> = [
1527
+ ['head', 5], ['chest', 6], ['legs', 7], ['feet', 8], ['offhand', 45],
1528
+ ];
1529
+
1530
+ function readEquipment(bot: any): GearPiece[] {
1531
+ const slots = bot.inventory?.slots;
1532
+ if (!slots) return [];
1533
+ const out: GearPiece[] = [];
1534
+ for (const [slot, idx] of GEAR_SLOT_INDEX) {
1535
+ const it = slots[idx] as ItemLike | null | undefined;
1536
+ if (!it?.name) continue;
1537
+ out.push({
1538
+ slot, name: it.name, durability: readDurability(it), enchantments: readEnchants(it, bot.registry),
1539
+ });
1540
+ }
1541
+ return out;
1542
+ }
1543
+
1544
+ /** 效果时长是刻,20 刻一秒;查不出名字的效果按 `effect_<id>` 照报,不吞 */
1545
+ function readEffects(bot: any): StatusEffect[] {
1546
+ const raw = bot.entity?.effects;
1547
+ if (!raw) return [];
1548
+ const out: StatusEffect[] = [];
1549
+ for (const key of Object.keys(raw)) {
1550
+ const e = raw[key];
1551
+ if (!e || typeof e.duration !== 'number' || e.duration <= 0) continue;
1552
+ out.push({
1553
+ name: bot.registry?.effects?.[e.id]?.name ?? `effect_${e.id}`,
1554
+ level: (e.amplifier ?? 0) + 1,
1555
+ seconds: Math.round(e.duration / 20),
1556
+ });
1557
+ }
1558
+ return out.sort((a, b) => b.seconds - a.seconds);
1559
+ }
1560
+
1561
+ export function snapshotFromBot(
1562
+ bot: any,
1563
+ opts?: {
1564
+ maxEntities?: number;
1565
+ invSynced?: boolean;
1566
+ scanBlocks?: boolean;
1567
+ /** 现实时刻按哪个时区报;缺省东八区 */
1568
+ timezone?: string;
1569
+ chestOf?: (dimension: string, p: { x: number; y: number; z: number }) => { items: ItemStack[] } | undefined;
1570
+ },
1571
+ ): WorldSnapshot {
1572
+ const maxEntities = opts?.maxEntities ?? 12;
1573
+ const me = bot.entity;
1574
+ const pos = me.position;
1575
+
1576
+ const entities: EntityInfo[] = [];
1577
+ for (const id of Object.keys(bot.entities)) {
1578
+ const e = bot.entities[id];
1579
+ if (!e || e === me || !e.position) continue;
1580
+ const d = e.position.distanceTo(pos);
1581
+ // 距离闸只有 AUDIBLE_RANGE 这一道(旧的 24 格 entityRange 闸已并进来)
1582
+ if (d > AUDIBLE_RANGE) continue;
1583
+ const name = e.type === 'player' ? (e.username ?? 'player') : (e.name ?? e.displayName ?? 'unknown');
1584
+ // 飞行中/插在地上的射弹:看得见也不报
1585
+ if (e.name && NOISE_ENTITIES.has(e.name)) continue;
1586
+ const kind = e.name === 'piglin' && piglinIsHostile(bot, e)
1587
+ ? 'hostile'
1588
+ : classifyEntity(e.type, e.name);
1589
+ const visible = canSeeEntity(bot, e);
1590
+ // 看不见的还得发得出声:落到 other 的(掉落物、经验球、矿车、展示框、盔甲架)
1591
+ // 与不发闲置音的水生动物都不进
1592
+ if (!visible && (kind === 'other' || SILENT_ENTITIES.has(name))) continue;
1593
+ const stack = droppedStackOf(e, bot.registry?.items);
1594
+ const note = villagerNote(e);
1595
+ entities.push({
1596
+ name,
1597
+ kind,
1598
+ distance: d,
1599
+ direction: bearing(e.position.x - pos.x, e.position.z - pos.z),
1600
+ dy: Math.round(e.position.y - pos.y),
1601
+ visible,
1602
+ ...(stack ? { item: stack } : {}),
1603
+ ...(note ? { note } : {}),
1604
+ });
1605
+ }
1606
+ entities.sort((a, b) => a.distance - b.distance);
1607
+
1608
+ const nearbyBlocks: WorldSnapshot['nearbyBlocks'] = [];
1609
+ const registry = bot.registry;
1610
+ const dimension = String(bot.game.dimension ?? 'overworld');
1611
+ const containers: WorldSnapshot['nearbyBlocks'] = [];
1612
+ if (opts?.scanBlocks !== false) {
1613
+ // 判据取代白名单之后扫描也塌成三次:水一次、岩浆一次(各自占满自己的 count),
1614
+ // 剩下所有该报的一次全局调用。count 2048 是实测值——150 个采样点上复现
1615
+ // "每种最近 8 处"所需的全局 count 最大 1542,不够时只会少报、不会多报。
1616
+ const spots = new Map<string, Array<{ x: number; y: number; z: number; distanceTo(o: unknown): number }>>();
1617
+ const record = (found: Array<{ x: number; y: number; z: number }>): void => {
1618
+ for (const p of found) {
1619
+ const name = bot.blockAt(p as never)?.name;
1620
+ if (!name) continue;
1621
+ const group = spots.get(name);
1622
+ if (group) group.push(p as never);
1623
+ else spots.set(name, [p as never]);
1624
+ }
1625
+ };
1626
+ for (const bulk of ['water', 'lava']) {
1627
+ const id = registry.blocksByName[bulk]?.id;
1628
+ if (id === undefined) continue;
1629
+ record(bot.findBlocks({ matching: [id], maxDistance: 16, count: 8 }));
1630
+ }
1631
+ // matching 传判据函数而不是 id 数组:mineflayer 对数组是 indexOf 线性查
1632
+ // (blocks.js:117),L6 下这张表有 900 多项、又几乎每个 section 都命中调色板筛,
1633
+ // 逐格线性比会把每份快照拖成上亿次比较。函数形态是它原生支持的,Set 查是 O(1)。
1634
+ const ids = reportableIds(registry.blocksByName);
1635
+ if (ids.size > 0) {
1636
+ record(bot.findBlocks({
1637
+ matching: ((b: { type: number } | null) => b !== null && ids.has(b.type)) as never,
1638
+ maxDistance: 16,
1639
+ count: 2048,
1640
+ }));
1641
+ }
1642
+
1643
+ for (const [blockName, group] of spots) {
1644
+ // findBlocks 按区块索引查、无视遮挡;摘要只报射线可及的。按距离先排,
1645
+ // 拿到要的那几块就收手——射线不便宜,不能对着上千格逐个打
1646
+ group.sort((a, b) => a.distanceTo(pos) - b.distanceTo(pos));
1647
+ const container = CONTAINER_BLOCKS.has(blockName);
1648
+ const want = container ? CONTAINER_NEARBY_CAP : 1;
1649
+ const visible: typeof group = [];
1650
+ for (const p of group) {
1651
+ if (!canSeeBlockAt(bot, p)) continue;
1652
+ visible.push(p);
1653
+ if (visible.length >= want) break;
1654
+ }
1655
+ for (const spot of visible) {
1656
+ const common = {
1657
+ name: blockName,
1658
+ distance: spot.distanceTo(pos),
1659
+ direction: bearing(spot.x - pos.x, spot.z - pos.z),
1660
+ dy: Math.round(spot.y - pos.y),
1661
+ x: spot.x, y: spot.y, z: spot.z,
1662
+ };
1663
+ if (container) {
1664
+ const rec = opts?.chestOf?.(dimension, spot);
1665
+ containers.push({ ...common, contents: rec ? rec.items : opts?.chestOf ? null : undefined });
1666
+ } else {
1667
+ nearbyBlocks.push({ ...common, ...blockState(bot, blockName, spot) });
1668
+ }
1669
+ }
1670
+ }
1671
+ }
1672
+ containers.sort((a, b) => a.distance - b.distance);
1673
+ nearbyBlocks.push(...containers.slice(0, CONTAINER_NEARBY_CAP));
1674
+ nearbyBlocks.sort((a, b) => a.distance - b.distance);
1675
+
1676
+ // 脚所在格为液体时报告该格,否则报告脚下格;inWater 只检查头部,不能识别浅水。
1677
+ const feetBlock = bot.blockAt(pos);
1678
+ const below = feetBlock && LIQUID_UNDERFOOT.has(feetBlock.name)
1679
+ ? feetBlock
1680
+ : bot.blockAt(pos.offset(0, -1, 0));
1681
+ const biome = biomeAt(bot, pos);
1682
+
1683
+ const inWater = headInWater(bot);
1684
+ const vel = me.velocity ?? { x: 0, y: 0, z: 0 };
1685
+ // 速度是每 tick 的位移,×20 换成格/秒
1686
+ const speed = Math.hypot(vel.x, vel.z) * 20;
1687
+ const vy = vel.y * 20;
1688
+ const onGround = Boolean(me.onGround);
1689
+ const motion: Motion =
1690
+ inWater && speed > 0.5 ? 'swimming'
1691
+ : !onGround && vy < -1.5 ? 'falling'
1692
+ : !onGround && vy > 1.5 ? 'rising'
1693
+ : speed < 0.5 ? 'still'
1694
+ : speed > 5.2 ? 'sprinting' : 'walking';
1695
+
1696
+ // 亮度按"眼睛看到的"算,天光入夜要扣,所以采样得先知道是不是夜里
1697
+ const timeOfDay = bot.time?.timeOfDay ?? 0;
1698
+
1699
+ return {
1700
+ position: { x: pos.x, y: pos.y, z: pos.z },
1701
+ dimension: String(bot.game.dimension ?? 'overworld'),
1702
+ health: bot.health ?? 20,
1703
+ food: bot.food ?? 20,
1704
+ // 旱地上的氧气读数是残留旧值,一律按满算;水下才用真实值(截到 0–20)
1705
+ oxygen: inWater ? Math.max(0, Math.min(20, bot.oxygenLevel ?? 20)) : 20,
1706
+ inWater,
1707
+ invSynced: opts?.invSynced ?? true,
1708
+ timeOfDay,
1709
+ realTime: nowIso(opts?.timezone ?? 'Asia/Shanghai'),
1710
+ light: sampleLight(bot, isNight(timeOfDay)),
1711
+ raining: isRaining(bot),
1712
+ biome,
1713
+ gameMode: String(bot.game.gameMode ?? 'survival'),
1714
+ heldItem: bot.heldItem ? bot.heldItem.name : null,
1715
+ inventory: (bot.inventory?.items() ?? []).map((it: any) => {
1716
+ const ench = readEnchants(it, bot.registry);
1717
+ return { name: it.name, count: it.count, ...(ench.length > 0 ? { enchantments: ench } : {}) };
1718
+ }),
1719
+ xpLevel: bot.experience?.level ?? 0,
1720
+ equipment: readEquipment(bot),
1721
+ effects: readEffects(bot),
1722
+ entities: entities.slice(0, maxEntities),
1723
+ entitiesOmitted: Math.max(0, entities.length - maxEntities),
1724
+ standingOn: below ? below.name : null,
1725
+ nearbyBlocks,
1726
+ blocksScanned: opts?.scanBlocks !== false,
1727
+ players: Object.keys(bot.players ?? {}),
1728
+ terrain: opts?.scanBlocks === false ? [] : sampleTerrain(bot, pos),
1729
+ motion,
1730
+ speed,
1731
+ facing: facingOf(me.yaw ?? 0),
1732
+ heading: speed < 0.5 ? null : bearing(vel.x, vel.z),
1733
+ onGround,
1734
+ };
1735
+ }