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,1763 @@
1
+ /**
2
+ * mc_do 的契约面:技能注册表。
3
+ *
4
+ * 每个技能在 `SKILLS` 里声明一次字段(名/类型/必填/范围/默认值/一行文档)与文档段落,
5
+ * 出口全部由这一份生成:`SKILL_NAMES`、mc_do / mc_scout 的 schema 与技能表、
6
+ * `parseSteps` / `parseScoutSteps`。mc_scout 是同一张表的只读切片:probe,以及
7
+ * 声明了 dryRun 的技能(入队时一律按试算跑)。结构约束只有一个家,长出新技能时同步。
8
+ *
9
+ * 横向规则(build 的 shape-锚点数、use 的 at/target 互斥、craft 的 item/grid 二选一)
10
+ * 不进表,走技能自己的 parse 钩子;钩子接管该步的全部校验与规范化。
11
+ */
12
+ import {
13
+ ANCHOR_COUNT, FACE_NAMES, SHAPE_NAMES,
14
+ type Anchor, type BlockFace, type BoxFill, type ShapeName,
15
+ } from './geometry.ts';
16
+ import { DIRECTIONS, type Direction } from './terrain.ts';
17
+ import { normalizeDimension } from './escape.ts';
18
+
19
+ /** 步骤依赖与验收的可选覆写;省略时使用执行器推导的因果依赖和验收规则。 */
20
+ export interface StepBounds {
21
+ /**
22
+ * 依赖的更早步骤,序号从 1 起;省略时按产出与消费关系推导,空数组表示独立。
23
+ * 显式依赖仅在上游 ok 或 partial 时放行;自动依赖另允许所需入料已在背包时放行。
24
+ * 执行始终按列表顺序,不按依赖重排。
25
+ */
26
+ needs?: number[];
27
+ /**
28
+ * 验收标准的**覆写**。缺省时判据由执行器按 `(skill, 参数)` 推(`deriveExpect`),
29
+ * 推不出来的才信技能自己的裁决。被叫停/抢占的步不评估。
30
+ */
31
+ expect?: Expectation;
32
+ }
33
+
34
+ /**
35
+ * 验收支持背包数量、位置、方块与手持物四种状态。
36
+ * 锚点按评估时刻脚下解析,与技能参数的相对坐标约定一致。
37
+ */
38
+ export type Expectation =
39
+ | { has: { item: string; count: number } }
40
+ | { near: Anchor; within?: number }
41
+ | { block: string; at: Anchor }
42
+ | { holding: { item: string } };
43
+
44
+ /** near 形态缺省的判定半径(格) */
45
+ export const NEAR_DEFAULT = 2;
46
+
47
+ /**
48
+ * 新任务与队列的关系。三态各对应她的一句话:
49
+ * `replace`「后面排的不算数了,改做这个」、`append`「手上和排着的做完再做这个」、
50
+ * `now`「别挖了,先插火把」。mc_do 与 mc_scout 是同一条队列,同一套三态。
51
+ */
52
+ export type QueueMode = 'replace' | 'append' | 'now';
53
+
54
+ export const QUEUE_MODES: readonly QueueMode[] = ['replace', 'append', 'now'];
55
+
56
+ /** mc_do / mc_scout 顶层的 queue 字段 */
57
+ export const QUEUE_SCHEMA: Record<string, unknown> = {
58
+ type: 'string',
59
+ enum: [...QUEUE_MODES],
60
+ description:
61
+ '这一单跟队列的关系。不写 = replace:撤掉排着的那些,接在正在做的那件后面(回执点名撤了谁);' +
62
+ 'append:排到队尾,排着的都保留;now:中断正在做的那件、插到队头立刻开做,排着的保留' +
63
+ '(正在逃命时不抢,排队头等它逃完)',
64
+ };
65
+
66
+ /** queue 字段的校验;不写 = replace */
67
+ export function parseQueueMode(raw: unknown): { mode: QueueMode } | { error: string } {
68
+ if (raw === undefined || raw === null) return { mode: 'replace' };
69
+ const s = str(raw);
70
+ if (!s || !(QUEUE_MODES as readonly string[]).includes(s)) {
71
+ return { error: `queue 只认 ${QUEUE_MODES.join('/')}(不写 = replace)` };
72
+ }
73
+ return { mode: s as QueueMode };
74
+ }
75
+
76
+ /** 贴面放置以参照格 at 与表面 face 定义落点,参数与 use_item_on 协议对应。 */
77
+ interface PlaceOnFace { at: Anchor; face: BlockFace }
78
+
79
+ export type AttackMode = 'auto' | 'melee' | 'ranged' | 'kite';
80
+
81
+ /** 一步技能:动作参数,外加边界声明(StepBounds)。 */
82
+ export type SkillCall = StepBounds & (
83
+ | { skill: 'goto'; at: Anchor; dimension?: string; groundY?: true; dryRun?: boolean }
84
+ | { skill: 'transit'; at: Anchor }
85
+ | { skill: 'goto_player'; name: string }
86
+ | { skill: 'follow'; name: string }
87
+ | { skill: 'find'; target: string; distance: number; direction?: Direction; until?: string[] }
88
+ | { skill: 'flee'; distance: number }
89
+ | { skill: 'surface' }
90
+ | { skill: 'collect'; block: string; count: number; buried?: boolean; mature?: boolean; tool?: string }
91
+ | { skill: 'fish'; at?: Anchor }
92
+ | { skill: 'build'; on: PlaceOnFace[]; material: string; dryRun?: boolean }
93
+ | { skill: 'build'; anchors: Anchor[]; material: string; shape?: ShapeName; fill?: BoxFill; dryRun?: boolean }
94
+ /**
95
+ * 按已装载的蓝图施工(判别式的第三形态,判据是 `blueprint` 在不在)。
96
+ * 形状与材料都在图里,所以这一形态一个 material 都不收;`at` 是蓝图 [0,0,0]
97
+ * 落在世界的哪一格(首次要给,续建从施工绑定里取),`stopAfter` 是 y 层号。
98
+ */
99
+ | {
100
+ skill: 'build';
101
+ blueprint: string;
102
+ at?: Anchor;
103
+ stopAfter?: number;
104
+ /** retrofit 现场有冲突时,明确同意清掉冲突格后施工。 */
105
+ confirm?: boolean;
106
+ /** 冲突格原样留着,先把能放的放上;回执点名跳过了哪几格。与 confirm 二选一。 */
107
+ skipConflicts?: boolean;
108
+ dryRun?: boolean;
109
+ }
110
+ | { skill: 'excavate'; shape: ShapeName; anchors: Anchor[]; fill?: BoxFill; dryRun?: boolean; tool?: string }
111
+ | { skill: 'tunnel'; at: Anchor; spiral?: boolean; dryRun?: boolean; until?: string[]; tool?: string }
112
+ | { skill: 'probe'; shape: ShapeName; anchors: Anchor[]; fill?: BoxFill; where?: string[] }
113
+ | { skill: 'craft'; item?: string; count: number; grid?: string[][] }
114
+ | { skill: 'smelt'; input: string; count: number; fuel: string; at?: Anchor }
115
+ | { skill: 'brew'; at?: Anchor; input: string; bottle: string; count: number; fuel: string }
116
+ | { skill: 'enchant'; at: Anchor; item: string; index?: number }
117
+ | { skill: 'eat'; item: string }
118
+ | { skill: 'attack'; target: string; mode?: AttackMode }
119
+ | { skill: 'equip'; item?: string; pick?: string }
120
+ | { skill: 'pickup'; item?: string }
121
+ | { skill: 'toss'; item: string; count: number; at?: Anchor; pick?: string }
122
+ | { skill: 'stow'; item: string; count: number; pick?: string }
123
+ | { skill: 'take'; item?: string; count?: number; at?: Anchor; all?: true; pick?: string }
124
+ | { skill: 'chat'; text: string }
125
+ | {
126
+ skill: 'use';
127
+ item?: string;
128
+ at?: Anchor;
129
+ target?: string;
130
+ index?: number;
131
+ times?: number;
132
+ /** 只对告示牌:右键打开编辑框之后把这几行字写上去(\n 分行,最多 4 行) */
133
+ text?: string;
134
+ /** 写在牌子背面(1.20 起两面各有一套字);缺省写正面 */
135
+ back?: true;
136
+ /** 右键落在哪一面;缺省顶面。贴墙的东西(展示框、画)靠它 */
137
+ face?: BlockFace;
138
+ }
139
+ /** 骑上/驾驭/下坐骑。off 单独一态;target 上坐骑;to 驾着走(可与 target 同给) */
140
+ | { skill: 'ride'; target?: string; to?: Anchor; off?: true }
141
+ /**
142
+ * 拴绳:拴住一只活物,牵着走到 to,到了松开(keep 就继续牵着)、或系到 tie 那根栅栏上。
143
+ * off 单独一态 = 松开现在牵着的那只。
144
+ */
145
+ | {
146
+ skill: 'lead';
147
+ target?: string;
148
+ to?: Anchor;
149
+ tolerance?: number;
150
+ tie?: Anchor;
151
+ keep?: true;
152
+ off?: true;
153
+ }
154
+ /** 铁砧:合修/改名。repair 与 combine 同义(两件同种或本体+附魔书放一起) */
155
+ | {
156
+ skill: 'anvil';
157
+ op: 'repair' | 'combine' | 'rename';
158
+ item: string;
159
+ with?: string;
160
+ name?: string;
161
+ at?: Anchor;
162
+ pick?: string;
163
+ withPick?: string;
164
+ }
165
+ /** 砂轮:除魔/合修,附魔按原版比例返经验 */
166
+ | { skill: 'grindstone'; item: string; with?: string; at?: Anchor; pick?: string; withPick?: string }
167
+ );
168
+
169
+ function str(v: unknown): string | null {
170
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
171
+ }
172
+
173
+ /** 返回 [lo,hi] 内的整数;缺省时用 fallback,越界时返回 null。 */
174
+ function intIn(v: unknown, lo: number, hi: number, fallback: number): number | null {
175
+ if (v === undefined || v === null) return fallback;
176
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
177
+ const n = Math.floor(v);
178
+ return n >= lo && n <= hi ? n : null;
179
+ }
180
+
181
+ /**
182
+ * 锚点结构校验:[x,y,z],分量是数字或非空字符串。相对值到执行时才解析。
183
+ *
184
+ * 光给一个字符串 = 路标名,当场查 mc_map 换成坐标。她已经维护着十几处路标并且
185
+ * 自己在用航点分解走长途,写名字是那条习惯与工具面之间最后一块缺口。
186
+ */
187
+ function anchorOf(v: unknown, marks?: MarkLookup): Anchor | null {
188
+ if (typeof v === 'string') {
189
+ const hit = v.trim() === '' ? null : marks?.(v.trim()) ?? null;
190
+ return !Array.isArray(hit) ? null : [hit[0], hit[1], hit[2]];
191
+ }
192
+ if (!Array.isArray(v) || v.length !== 3) return null;
193
+ for (const cc of v) {
194
+ if (typeof cc === 'number' && Number.isFinite(cc)) continue;
195
+ if (typeof cc === 'string' && cc.trim() !== '') continue;
196
+ return null;
197
+ }
198
+ return [v[0], v[1], v[2]] as Anchor;
199
+ }
200
+
201
+ /**
202
+ * `at` 写的是名字却没换出坐标时补的那句事实。分两种说法:路标表里查无此名,
203
+ * 与这个部署根本没接路标表 —— 她要改的东西不一样。
204
+ */
205
+ function markMissNote(v: unknown, marks?: MarkLookup): string {
206
+ if (typeof v !== 'string' || v.trim() === '') return '';
207
+ if (marks === undefined) return ';这一单没接路标表,at 只认坐标';
208
+ const hit = marks(v.trim());
209
+ return hit && !Array.isArray(hit)
210
+ ? `;${hit.error}`
211
+ : `;mc_map 里没有登记叫「${v.trim()}」的路标`;
212
+ }
213
+
214
+ function anchorsOf(v: unknown, n: number, marks?: MarkLookup): Anchor[] | null {
215
+ if (!Array.isArray(v) || v.length !== n) return null;
216
+ const out: Anchor[] = [];
217
+ for (const a of v) {
218
+ const anchor = anchorOf(a, marks);
219
+ if (!anchor) return null;
220
+ out.push(anchor);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** 任意长度的锚点清单(1-max);形状不参与,给几个就是几个格子 */
226
+ function anchorListOf(v: unknown, max: number, marks?: MarkLookup): Anchor[] | null {
227
+ if (!Array.isArray(v) || v.length === 0 || v.length > max) return null;
228
+ return anchorsOf(v, v.length, marks);
229
+ }
230
+
231
+ /**
232
+ * 她自己摆的合成格:按行写的名字二维数组,空位写 null/""。
233
+ * 只校验形状(最多 3×3)与元素类型 —— 这几个名字配不配得出东西由服务端说了算。
234
+ */
235
+ function gridOf(raw: unknown): string[][] | null {
236
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 3) return null;
237
+ const out: string[][] = [];
238
+ for (const row of raw) {
239
+ if (!Array.isArray(row) || row.length === 0 || row.length > 3) return null;
240
+ const cells: string[] = [];
241
+ for (const cell of row) {
242
+ if (cell === null || cell === undefined || cell === '') { cells.push(''); continue; }
243
+ if (typeof cell !== 'string') return null;
244
+ cells.push(cell.trim());
245
+ }
246
+ out.push(cells);
247
+ }
248
+ return out.some((r) => r.some((n) => n !== '')) ? out : null;
249
+ }
250
+
251
+ /** 一个物品栏装得下的上限:36 格 × 64 */
252
+ const HAS_COUNT_MAX = 2304;
253
+
254
+ /** expect 的结构校验:四形态有且只有一个;at 是错误文案里的「第 N 步」 */
255
+ function expectOf(v: unknown, at: string): { expect: Expectation } | { error: string } {
256
+ const usage = `${at}的 expect 要 {"has":{"item","count"}} / {"near":[x,y,z],"within"} / ` +
257
+ `{"block","at":[x,y,z]} / {"holding":{"item"}} 四种之一`;
258
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return { error: usage };
259
+ const e = v as Record<string, unknown>;
260
+ const forms = ['has', 'near', 'block', 'holding'].filter((k) => e[k] !== undefined);
261
+ if (forms.length !== 1) return { error: usage };
262
+ switch (forms[0]) {
263
+ case 'has': {
264
+ const h = e.has;
265
+ if (typeof h !== 'object' || h === null || Array.isArray(h)) return { error: usage };
266
+ const item = str((h as Record<string, unknown>).item);
267
+ if (!item) return { error: `${at}的 expect.has 要 item(物品英文 id)` };
268
+ const count = intIn((h as Record<string, unknown>).count, 1, HAS_COUNT_MAX, 0);
269
+ if (!count) return { error: `${at}的 expect.has 的 count 要在 1-${HAS_COUNT_MAX} 之间` };
270
+ return { expect: { has: { item, count } } };
271
+ }
272
+ case 'holding': {
273
+ const h = e.holding;
274
+ if (typeof h !== 'object' || h === null || Array.isArray(h)) return { error: usage };
275
+ const item = str((h as Record<string, unknown>).item);
276
+ if (!item) return { error: `${at}的 expect.holding 要 item(物品英文 id)` };
277
+ return { expect: { holding: { item } } };
278
+ }
279
+ case 'near': {
280
+ const near = anchorOf(e.near);
281
+ if (!near) return { error: `${at}的 expect.near 要 [x,y,z](数字或 "~"/"~-3" 相对写法)` };
282
+ const within = intIn(e.within, 1, 128, NEAR_DEFAULT);
283
+ if (within === null) return { error: `${at}的 expect.within 要在 1-128 格之间` };
284
+ return { expect: within === NEAR_DEFAULT ? { near } : { near, within } };
285
+ }
286
+ default: {
287
+ const block = str(e.block);
288
+ if (!block) return { error: `${at}的 expect.block 要方块英文 id` };
289
+ const spot = anchorOf(e.at);
290
+ if (!spot) return { error: `${at}的 expect 形态 block 还要 at:[x,y,z](数字或 "~"/"~-3" 相对写法)` };
291
+ return { expect: { block, at: spot } };
292
+ }
293
+ }
294
+ }
295
+
296
+ /** needs 的结构校验:整数数组,只准引用更早的步(1 ≤ n < 当前序号) */
297
+ function needsOf(v: unknown, index: number, at: string): { needs: number[] } | { error: string } {
298
+ if (!Array.isArray(v)) {
299
+ return { error: `${at}的 needs 要是更早步骤的序号数组(1 起),[] = 不依赖任何步` };
300
+ }
301
+ if (index === 0 && v.length > 0) {
302
+ return { error: `${at}前面没有可引用的步,needs 只能是 []` };
303
+ }
304
+ const needs: number[] = [];
305
+ for (const n of v) {
306
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > index) {
307
+ return { error: `${at}的 needs 只能引用更早的步(1 到 ${index}),给了 ${JSON.stringify(n)}` };
308
+ }
309
+ needs.push(n);
310
+ }
311
+ return { needs };
312
+ }
313
+
314
+ /**
315
+ * 注册表字段:结构声明 + 进 schema 分支的一行文档。
316
+ * kind 驱动表校验与默认 schema 片段;`schema` 覆盖默认生成(goto 的 at 收 2-3 分量这类特例)。
317
+ * `opaque` 只进 schema,校验完全在技能的 parse 钩子里(anchors/grid/scaffold 这类复合结构)。
318
+ */
319
+ type FieldSpec = {
320
+ key: string;
321
+ /** 进 schema 分支的 description;类型与枚举自明时不写 */
322
+ doc?: string;
323
+ schema?: Record<string, unknown>;
324
+ } & (
325
+ | { kind: 'string'; required?: true; hint?: string }
326
+ /** 一串英文 id(可带 `#类别`);空数组等于没写 */
327
+ | { kind: 'names'; hint?: string }
328
+ | { kind: 'int'; lo: number; hi: number; def: number; unit?: string }
329
+ | { kind: 'anchor'; required?: true; error: string }
330
+ | { kind: 'flag' }
331
+ | { kind: 'enum'; values: readonly string[]; required?: true; error: string }
332
+ | { kind: 'opaque'; required?: true; schema: Record<string, unknown> }
333
+ );
334
+
335
+ /**
336
+ * 解析对这一步做过的改动:某个字段被丢掉,或按别的意思收下了。
337
+ * 校验通不过时整批退回,说的是错误本身,没有这个。
338
+ */
339
+ type StepNote =
340
+ | { field: string; given: unknown; kind: 'dropped'; why: string }
341
+ | { field: string; given: unknown; kind: 'rewritten'; as: string };
342
+
343
+ /** 解析提示附带从 1 起的步骤编号,由受理回执报告参数调整。 */
344
+ export type ParseNote = StepNote & { step: number };
345
+
346
+ /** 这些改动只有一份渲染,回执与日志都用它。 */
347
+ export function parseNoteText(n: ParseNote): string {
348
+ const wrote = `第 ${n.step} 步写的 ${n.field}:${JSON.stringify(n.given)}`;
349
+ return n.kind === 'dropped' ? `${wrote},${n.why},忽略了` : `${wrote},按${n.as}理解`;
350
+ }
351
+
352
+ type ParseResult = { step: SkillCall; notes?: StepNote[] } | { error: string };
353
+
354
+ interface SkillSpec {
355
+ name: SkillCall['skill'];
356
+ /** SKILL_DOC 里这一技能的段落:示例行 + 语义,完全手写 */
357
+ doc: string;
358
+ fields: FieldSpec[];
359
+ /** 横向规则钩子:接管整步校验与规范化;没给钩子的技能走表驱动 */
360
+ parse?: (c: Record<string, unknown>, at: string, marks?: MarkLookup) => ParseResult;
361
+ }
362
+
363
+ /**
364
+ * 路标名 → 坐标。 World 持有 mc_map 那张表,解析器只收这一个只读查询;
365
+ * 不接(台架、纯结构测试)时 `at` 只认坐标数组。
366
+ */
367
+ export type MarkLookup = (name: string) => [number, number, number] | { error: string } | null;
368
+
369
+ const RELATIVE_HINT = '数字或 "~"/"~-3" 相对写法';
370
+
371
+ /** 一步 use 里连着右键的次数上限 */
372
+ const USE_TIMES_MAX = 16;
373
+
374
+ /**
375
+ * lead 的 tolerance:「牵到了」判到 to 多少格内算数。默认 3 —— 牵绳本来就是软的,
376
+ * 原版把活物拉到落脚点上是做不到的事,给一个圈才说得出「到了没有」。
377
+ */
378
+ const LEAD_TOL = { lo: 1, hi: 16, def: 3 } as const;
379
+
380
+ function parseGoto(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
381
+ // [x,z] 两分量 = 按地表落脚;y 在执行那一刻按 (x,z) 的最高实心块解
382
+ const flat = Array.isArray(c.at) && c.at.length === 2;
383
+ const to = anchorOf(flat ? [(c.at as unknown[])[0], 0, (c.at as unknown[])[1]] : c.at, marks);
384
+ if (!to) {
385
+ return {
386
+ error: `${at} goto 要 at:[x,y,z] / [x,z](${RELATIVE_HINT}),或一个 mc_map 路标名`
387
+ + markMissNote(c.at, marks),
388
+ };
389
+ }
390
+ return {
391
+ step: {
392
+ skill: 'goto', at: to,
393
+ ...(str(c.dimension) ? { dimension: normalizeDimension(str(c.dimension)) } : {}),
394
+ ...(flat ? { groundY: true as const } : {}),
395
+ ...(c.dryRun === true ? { dryRun: true } : {}),
396
+ },
397
+ };
398
+ }
399
+
400
+ /** 一步 build 里最多放几处(格子清单形态与贴面形态共用) */
401
+ const PLACE_SPOTS_MAX = 16;
402
+
403
+ /** 一处贴面放置的结构校验:{"at":[x,y,z],"face":"up"} */
404
+ function placeOnFaceOf(v: unknown): PlaceOnFace | null {
405
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return null;
406
+ const o = v as Record<string, unknown>;
407
+ const spot = anchorOf(o.at);
408
+ const face = str(o.face);
409
+ if (!spot || !face || !(FACE_NAMES as string[]).includes(face)) return null;
410
+ return { at: spot, face: face as BlockFace };
411
+ }
412
+
413
+ /**
414
+ * build 的贴面形态。判别式的第二形态,判据是 `on` 在不在——**不重载 `anchors`**:
415
+ * 同一个字段在有没有 face 时含义不同就是二义性字段。
416
+ */
417
+ function parseBuildOnFaces(c: Record<string, unknown>, at: string): ParseResult {
418
+ if (c.anchors !== undefined && c.anchors !== null) {
419
+ return { error: `${at} build 的 on(贴着某一面放)与 anchors(直接点格子)只能给一个` };
420
+ }
421
+ const material = str(c.material);
422
+ if (!material) return { error: `${at} build 要 material(方块英文 id)` };
423
+ if (!Array.isArray(c.on) || c.on.length === 0 || c.on.length > PLACE_SPOTS_MAX) {
424
+ return { error: `${at} build 的 on 要 1-${PLACE_SPOTS_MAX} 处,每处 {"at":[x,y,z],"face":"up"}` };
425
+ }
426
+ const on: PlaceOnFace[] = [];
427
+ for (const [i, raw] of c.on.entries()) {
428
+ const spot = placeOnFaceOf(raw);
429
+ if (!spot) {
430
+ return {
431
+ error: `${at} on 的第 ${i + 1} 处要 {"at":[x,y,z],"face":…}` +
432
+ `(at 是参照方块那一格,${RELATIVE_HINT};face 只认 ${FACE_NAMES.join('/')})`,
433
+ };
434
+ }
435
+ on.push(spot);
436
+ }
437
+ const notes: StepNote[] = [];
438
+ for (const key of ['shape', 'fill'] as const) {
439
+ const given = c[key];
440
+ if (given !== undefined && given !== null) {
441
+ notes.push({ field: key, given, kind: 'dropped', why: '贴面放置是一处一处的,没有形状' });
442
+ }
443
+ }
444
+ const step: SkillCall = { skill: 'build', material, on, ...(c.dryRun === true ? { dryRun: true } : {}) };
445
+ return notes.length > 0 ? { step, notes } : { step };
446
+ }
447
+
448
+ /**
449
+ * build 的蓝图形态。判据是 `blueprint` 在不在 —— 形状、材料、每一格放什么都在
450
+ * 图里,所以这一形态把 material/shape/fill/on/anchors 一概当误写丢掉并点名
451
+ * (静默吃掉参数是这条链上最贵的一类失败)。
452
+ */
453
+ function parseBuildBlueprint(c: Record<string, unknown>, at: string): ParseResult {
454
+ const key = str(c.blueprint);
455
+ if (!key) return { error: `${at} build 的 blueprint 要一个蓝图键(字符串)` };
456
+ const notes: StepNote[] = [];
457
+ for (const field of ['material', 'shape', 'fill', 'on', 'anchors'] as const) {
458
+ const given = c[field];
459
+ if (given !== undefined && given !== null) {
460
+ notes.push({ field, given, kind: 'dropped', why: '按蓝图施工时形状与材料都在图里' });
461
+ }
462
+ }
463
+ let anchor: Anchor | undefined;
464
+ if (c.at !== undefined && c.at !== null) {
465
+ const a = anchorOf(c.at);
466
+ if (!a) {
467
+ return {
468
+ error: `${at} build 的 at 要 [x,y,z](蓝图 [0,0,0] 落在世界的哪一格;${RELATIVE_HINT})`,
469
+ };
470
+ }
471
+ anchor = a;
472
+ }
473
+ let stopAfter: number | undefined;
474
+ if (c.stopAfter !== undefined && c.stopAfter !== null) {
475
+ if (typeof c.stopAfter !== 'number' || !Number.isSafeInteger(c.stopAfter) || c.stopAfter < 0) {
476
+ return { error: `${at} build 的 stopAfter 要一个非负整数 y 层号(0 是最低层)` };
477
+ }
478
+ stopAfter = c.stopAfter;
479
+ }
480
+ const step: SkillCall = {
481
+ skill: 'build', blueprint: key,
482
+ ...(anchor ? { at: anchor } : {}),
483
+ ...(stopAfter !== undefined ? { stopAfter } : {}),
484
+ ...(c.confirm === true ? { confirm: true } : {}),
485
+ ...(c.skipConflicts === true ? { skipConflicts: true } : {}),
486
+ ...(c.dryRun === true ? { dryRun: true } : {}),
487
+ };
488
+ return notes.length > 0 ? { step, notes } : { step };
489
+ }
490
+
491
+ /**
492
+ * find 站着扫的最远距离(格)。等于感知半径:再远的方块进不了 findBlocks 的区块索引,
493
+ * 也谈不上"看得见"。要更远只能走过去 —— 那就得给 direction。
494
+ */
495
+ export const FIND_STATIC_MAX = 48;
496
+
497
+ /** `until` 一次最多点几样(名单与类别合计) */
498
+ const UNTIL_NAMES_MAX = 12;
499
+
500
+ /** `probe` 的 `where` 每样最多列几处坐标(按远近);总数照实报 */
501
+ export const PROBE_WHERE_SHOWN = 6;
502
+
503
+ /**
504
+ * `until` 支持的类别写法(`"#ores"` 这种)。
505
+ *
506
+ * 本该用 minecraft-data 的方块 tag 展开,但本仓库钉住的 registry(prismarine-registry
507
+ * 1.12 / minecraft-data 3.112)**没有 tag 数据面** —— 逐版本核过,`blockTags` 不存在。
508
+ * 于是退化成这几个内置类别;它们写成**名字谓词**而不是写死的 id 名单,换版本不会
509
+ * 漏掉新方块。执行器先探一次 registry 的 tag 面(将来有了就走那条),取不到才落到这里。
510
+ */
511
+ export const UNTIL_CATEGORIES: Readonly<Record<string, (name: string) => boolean>> = {
512
+ ores: (n) => n.endsWith('_ore') || n === 'ancient_debris',
513
+ logs: (n) => n.endsWith('_log') || n.endsWith('_stem'),
514
+ leaves: (n) => n.endsWith('_leaves'),
515
+ chests: (n) => n === 'chest' || n === 'trapped_chest' || n === 'barrel',
516
+ liquids: (n) => n === 'water' || n === 'lava',
517
+ beds: (n) => n.endsWith('_bed'),
518
+ };
519
+
520
+ /** 文档与错误文案里那一串类别名(带 `#`) */
521
+ export const UNTIL_CATEGORY_DOC = Object.keys(UNTIL_CATEGORIES).map((k) => `#${k}`).join('/');
522
+
523
+ function parseShaped(skill: 'build' | 'excavate' | 'probe') {
524
+ return (c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult => {
525
+ // 判别式三形态,判据各是一个独占字段:blueprint(按图盖)→ on(贴面)→ anchors(点格子)
526
+ if (skill === 'build' && c.blueprint !== undefined && c.blueprint !== null) {
527
+ return parseBuildBlueprint(c, at);
528
+ }
529
+ if (skill === 'build' && c.on !== undefined && c.on !== null) return parseBuildOnFaces(c, at);
530
+ // build 不写 shape = 就这些格,一步放 N 处、各报各的;写了 shape 才按形状连片铺
531
+ const listed = skill === 'build' && (c.shape === undefined || c.shape === null);
532
+ const shape = listed ? null : str(c.shape);
533
+ if (!listed && (!shape || !(SHAPE_NAMES as string[]).includes(shape))) {
534
+ return { error: `${at} ${skill} 的 shape 要是 ${SHAPE_NAMES.join('/')} 之一` };
535
+ }
536
+ const anchors = listed
537
+ ? anchorListOf(c.anchors, PLACE_SPOTS_MAX, marks)
538
+ : anchorsOf(c.anchors, ANCHOR_COUNT[shape as ShapeName], marks);
539
+ if (!anchors) {
540
+ return {
541
+ error: listed
542
+ ? `${at} build 不写 shape 时 anchors 是 1-${PLACE_SPOTS_MAX} 个格子,` +
543
+ `每个是 [x,y,z](${RELATIVE_HINT})`
544
+ : `${at} ${shape} 要 ${ANCHOR_COUNT[shape as ShapeName]} 个锚点,` +
545
+ `每个是 [x,y,z](${RELATIVE_HINT})`,
546
+ };
547
+ }
548
+ let fill: BoxFill | undefined;
549
+ if (c.fill !== undefined) {
550
+ const f = str(c.fill);
551
+ if (f !== 'solid' && f !== 'outline' && f !== 'edges') {
552
+ // `hollow` 是原版 /fill 的模式名,含义是"外壳 + 内部清成空气",与这里的
553
+ // outline(只动外壳)差一整个内部。收下它当别名等于把假朋友留着,所以退回
554
+ // 并当场说清两者的分别 —— 这也是改名唯一的通知口。
555
+ return {
556
+ error: f === 'hollow'
557
+ ? `${at} 只动外壳写 fill:"outline";原版 /fill 的 hollow 还会把内部清成空气,这里没有那个模式`
558
+ : `${at} fill 只认 solid/outline/edges`,
559
+ };
560
+ }
561
+ fill = f;
562
+ }
563
+ const dryRun = c.dryRun === true;
564
+ if (skill === 'build') {
565
+ const material = str(c.material);
566
+ if (!material) return { error: `${at} build 要 material(方块英文 id)` };
567
+ return {
568
+ step: {
569
+ skill, anchors, material,
570
+ ...(shape ? { shape: shape as ShapeName } : {}),
571
+ ...(fill ? { fill } : {}), ...(dryRun ? { dryRun } : {}),
572
+ },
573
+ };
574
+ }
575
+ if (skill === 'excavate') {
576
+ const tool = str(c.tool);
577
+ if (c.tool !== undefined && c.tool !== null && !tool) {
578
+ return { error: `${at} excavate 的 tool 要物品英文 id 或 "fastest"` };
579
+ }
580
+ return {
581
+ step: {
582
+ skill, shape: shape as ShapeName, anchors,
583
+ ...(fill ? { fill } : {}), ...(dryRun ? { dryRun } : {}), ...(tool ? { tool } : {}),
584
+ },
585
+ };
586
+ }
587
+ const where = nameListOf(c.where, at, 'probe 的 where');
588
+ if (where !== null && 'error' in where) return { error: where.error };
589
+ return {
590
+ step: {
591
+ skill, shape: shape as ShapeName, anchors,
592
+ ...(fill ? { fill } : {}), ...(where ? { where: where.names } : {}),
593
+ },
594
+ };
595
+ };
596
+ }
597
+
598
+ /** 一串英文 id:没写(或写了个空数组)给 null,写错给 error */
599
+ function nameListOf(
600
+ v: unknown, at: string, what: string,
601
+ ): { names: string[] } | { error: string } | null {
602
+ if (v === undefined || v === null) return null;
603
+ if (!Array.isArray(v) || v.some((s) => typeof s !== 'string')) {
604
+ return { error: `${at} ${what} 要是一串英文 id(数组)` };
605
+ }
606
+ if (v.length > UNTIL_NAMES_MAX) return { error: `${at} ${what} 最多 ${UNTIL_NAMES_MAX} 项` };
607
+ const names = (v as string[]).map((s) => s.trim()).filter(Boolean);
608
+ return names.length > 0 ? { names } : null;
609
+ }
610
+
611
+ function parseUse(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
612
+ const item = str(c.item);
613
+ const useAt = c.at === undefined ? null : anchorOf(c.at, marks);
614
+ if (c.at !== undefined && !useAt) {
615
+ return { error: `${at} use 的 at 要 [x,y,z](${RELATIVE_HINT})${markMissNote(c.at, marks)}` };
616
+ }
617
+ const target = str(c.target);
618
+ if (useAt && target) {
619
+ return { error: `${at} use 的 at 与 target 只能给一个` };
620
+ }
621
+ // 空手又不说对哪儿使,右键不出任何东西
622
+ if (!item && !useAt && !target) {
623
+ return { error: `${at} use 至少要给 item,或 at/target` };
624
+ }
625
+
626
+ const times = intIn(c.times, 1, USE_TIMES_MAX, 1);
627
+ if (times === null) return { error: `${at} use 的 times 要在 1-${USE_TIMES_MAX} 之间` };
628
+ let index: number | undefined;
629
+ if (c.index !== undefined) {
630
+ if (!target) return { error: `${at} use 的 index 是报价菜单序号,要和 target 一起给` };
631
+ const i = intIn(c.index, 1, 99, 1);
632
+ if (i === null) return { error: `${at} use 的 index 要是报价菜单里的序号(1 起)` };
633
+ index = i;
634
+ }
635
+ // 牌子上的字:原版一块牌子四行、每行 45 个字符。超了当场说清楚超在哪一行,
636
+ // 不交给下游 —— mineflayer 的 updateSign 越界时只 emit 一个 error 就静默返回。
637
+ let text: string | undefined;
638
+ let textNote: StepNote | undefined;
639
+ if (c.text !== undefined && c.text !== null) {
640
+ if (typeof c.text !== 'string') return { error: `${at} use 的 text 要是字符串(告示牌上的字,\\n 分行)` };
641
+ if (!useAt) return { error: `${at} use 的 text 是写在某一块告示牌上的,要和 at 一起给` };
642
+ // 仅含字面 \n 且没有真实换行时,尝试将其规范化为换行。
643
+ // 规范化后不超过四行才采用,并以该文本继续校验。
644
+ let given = c.text;
645
+ if (!given.includes('\n') && given.includes('\\n')) {
646
+ const unescaped = given.split('\\n').join('\n');
647
+ if (unescaped.split('\n').length <= SIGN_LINES) {
648
+ given = unescaped;
649
+ textNote = { field: 'text', given: c.text, kind: 'rewritten', as: `字面「\\n」按分行符收下,归一成 ${given.split('\n').length} 行` };
650
+ }
651
+ }
652
+ const lines = given.split('\n');
653
+ if (lines.length > SIGN_LINES) {
654
+ return { error: `${at} 告示牌一共 ${SIGN_LINES} 行,给了 ${lines.length} 行` };
655
+ }
656
+ const over = lines.findIndex((line) => line.length > SIGN_LINE_CHARS);
657
+ if (over >= 0) {
658
+ return { error: `${at} 告示牌每行最多 ${SIGN_LINE_CHARS} 个字符,第 ${over + 1} 行有 ${lines[over].length} 个` };
659
+ }
660
+ text = given;
661
+ }
662
+ if (c.back !== undefined && c.back !== null && text === undefined) {
663
+ return { error: `${at} use 的 back 是"写在牌子背面",要和 text 一起给` };
664
+ }
665
+ const back = c.back === true;
666
+ let face: BlockFace | undefined;
667
+ if (c.face !== undefined && c.face !== null) {
668
+ const f = str(c.face);
669
+ if (!f || !(FACE_NAMES as string[]).includes(f)) {
670
+ return { error: `${at} use 的 face 要是 ${FACE_NAMES.join('/')} 之一` };
671
+ }
672
+ if (!useAt) return { error: `${at} use 的 face 是"右键那一格的哪一面",要和 at 一起给` };
673
+ face = f as BlockFace;
674
+ }
675
+ return {
676
+ step: {
677
+ skill: 'use',
678
+ ...(item ? { item } : {}),
679
+ ...(useAt ? { at: useAt } : {}),
680
+ ...(target ? { target } : {}),
681
+ ...(index !== undefined ? { index } : {}),
682
+ ...(times > 1 ? { times } : {}),
683
+ ...(text !== undefined ? { text } : {}),
684
+ ...(back ? { back: true as const } : {}),
685
+ ...(face ? { face } : {}),
686
+ },
687
+ ...(textNote ? { notes: [textNote] } : {}),
688
+ };
689
+ }
690
+
691
+ /** 原版一块告示牌的行数与每行字符数上限(1.20.6 同 1.14 起未变)。 */
692
+ const SIGN_LINES = 4;
693
+ const SIGN_LINE_CHARS = 45;
694
+
695
+ /** 空着的那只手在游戏里就是 `air`:她照着游戏这么写,收下并回念成「空手」。 */
696
+ const EMPTY_HAND_NAMES = ['air', 'minecraft:air'];
697
+
698
+ function parseEquip(c: Record<string, unknown>, at: string): ParseResult {
699
+ const item = str(c.item);
700
+ const pick = str(c.pick);
701
+ if (!item) {
702
+ if (pick) return { error: `${at} equip 的 pick 是从同 id 的几件里挑一件,要和 item 一起给` };
703
+ return { step: { skill: 'equip' } };
704
+ }
705
+ if (EMPTY_HAND_NAMES.includes(item)) {
706
+ return {
707
+ step: { skill: 'equip' },
708
+ notes: [{ field: 'item', given: c.item, kind: 'rewritten', as: '空手' }],
709
+ };
710
+ }
711
+ return { step: { skill: 'equip', item, ...(pick ? { pick } : {}) } };
712
+ }
713
+
714
+ /** 原版铁砧改名框的字符上限(ServerboundRenameItem 超长直接丢) */
715
+ const ANVIL_NAME_MAX = 50;
716
+
717
+ /**
718
+ * 挑选词:同 id 的几件里点名哪一件。六个技能共用一句说法,匹配规则见 item-pick.ts。
719
+ */
720
+ const PICK_DOC = '同 id 的几件里挑哪一件:写清单括号里的字(「无限」「效率IV」,英文附魔 id 也认);'
721
+ + '不写 = 撞上哪件算哪件。按内容匹配,不是序号,存掉一件不影响其余几件怎么写';
722
+
723
+ /** 骑乘:off / target / to 三个入参的横向规则都在这儿说清,不静默吃字段。 */
724
+ function parseRide(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
725
+ const off = c.off === true;
726
+ const target = str(c.target);
727
+ const to = c.to === undefined || c.to === null ? null : anchorOf(c.to, marks);
728
+ if (c.to !== undefined && c.to !== null && !to) {
729
+ return { error: `${at} ride 的 to 要 [x,y,z](${RELATIVE_HINT})${markMissNote(c.to, marks)}` };
730
+ }
731
+ if (c.off !== undefined && c.off !== true) {
732
+ return { error: `${at} ride 的 off 只认 true(下坐骑写 {"skill":"ride","off":true})` };
733
+ }
734
+ if (off && (target || to)) {
735
+ return { error: `${at} ride 的 off 是下来,不能和 target/to 一起给` };
736
+ }
737
+ if (!off && !target && !to) {
738
+ return { error: `${at} ride 要 target(骑上哪种)或 to(骑着现在的坐骑去哪),下来写 off:true` };
739
+ }
740
+ return {
741
+ step: {
742
+ skill: 'ride',
743
+ ...(target ? { target } : {}),
744
+ ...(to ? { to } : {}),
745
+ ...(off ? { off: true as const } : {}),
746
+ },
747
+ };
748
+ }
749
+
750
+ /**
751
+ * 拴绳:三种形态各自成立(只拴上/牵着走/松开),照 ride 的写法当场点名缺什么。
752
+ * `tie` 是「牵到了就系在这根栅栏上」,单独给它而不给 to 也成立(人已经牵着的时候)。
753
+ */
754
+ function parseLead(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
755
+ const target = str(c.target);
756
+ const to = c.to === undefined || c.to === null ? null : anchorOf(c.to, marks);
757
+ const tie = c.tie === undefined || c.tie === null ? null : anchorOf(c.tie, marks);
758
+ const off = c.off === true;
759
+ if (c.to !== undefined && c.to !== null && !to) {
760
+ return { error: `${at} lead 的 to 要 [x,y,z](${RELATIVE_HINT})${markMissNote(c.to, marks)}` };
761
+ }
762
+ if (c.tie !== undefined && c.tie !== null && !tie) {
763
+ return { error: `${at} lead 的 tie 要 [x,y,z](栅栏那一格;${RELATIVE_HINT})${markMissNote(c.tie, marks)}` };
764
+ }
765
+ if (c.off !== undefined && c.off !== true) {
766
+ return { error: `${at} lead 的 off 只认 true(松开写 {"skill":"lead","off":true})` };
767
+ }
768
+ if (c.keep !== undefined && c.keep !== true) {
769
+ return { error: `${at} lead 的 keep 只认 true` };
770
+ }
771
+ if (off && (target || to || tie || c.keep === true)) {
772
+ return { error: `${at} lead 的 off 是松开,不能和 target/to/tie/keep 一起给` };
773
+ }
774
+ if (!off && !target && !to && !tie) {
775
+ return { error: `${at} lead 要 target(拴哪只)或 to(把现在牵着的牵去哪),松开写 off:true` };
776
+ }
777
+ const tol = intIn(c.tolerance, LEAD_TOL.lo, LEAD_TOL.hi, LEAD_TOL.def);
778
+ if (tol === null) {
779
+ return { error: `${at} lead 的 tolerance 要在 ${LEAD_TOL.lo}-${LEAD_TOL.hi} 格之间` };
780
+ }
781
+ return {
782
+ step: {
783
+ skill: 'lead',
784
+ ...(target ? { target } : {}),
785
+ ...(to ? { to, tolerance: tol } : {}),
786
+ ...(tie ? { tie } : {}),
787
+ ...(c.keep === true ? { keep: true as const } : {}),
788
+ ...(off ? { off: true as const } : {}),
789
+ },
790
+ };
791
+ }
792
+
793
+ /** 铁砧:op 决定哪些字段成立;缺什么当场点名,不留到执行期。 */
794
+ function parseAnvil(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
795
+ const op = str(c.op);
796
+ if (!op || !['repair', 'combine', 'rename'].includes(op)) {
797
+ return { error: `${at} anvil 的 op 只认 repair/combine/rename` };
798
+ }
799
+ const item = str(c.item);
800
+ if (!item) return { error: `${at} anvil 要 item(左格那件东西的物品英文 id)` };
801
+ const withItem = str(c.with);
802
+ const name = str(c.name);
803
+ if ((op === 'repair' || op === 'combine') && !withItem) {
804
+ return { error: `${at} anvil 的 op:"${op}" 要 with(右格放什么:同种的另一件、附魔书或修补材料)` };
805
+ }
806
+ if (op === 'rename') {
807
+ if (!name) return { error: `${at} anvil 的 op:"rename" 要 name(新名字)` };
808
+ if (name.length > ANVIL_NAME_MAX) {
809
+ return { error: `${at} anvil 的 name 最多 ${ANVIL_NAME_MAX} 个字符,给了 ${name.length} 个` };
810
+ }
811
+ }
812
+ if (op !== 'rename' && name !== null) {
813
+ return { error: `${at} anvil 的 name 只跟 op:"rename" 一起用;合修顺便改名分两步做` };
814
+ }
815
+ const anvilAt = c.at === undefined || c.at === null ? null : anchorOf(c.at, marks);
816
+ if (c.at !== undefined && c.at !== null && !anvilAt) {
817
+ return { error: `${at} anvil 的 at 要 [x,y,z](${RELATIVE_HINT})${markMissNote(c.at, marks)}` };
818
+ }
819
+ const pick = str(c.pick);
820
+ const withPick = str(c.withPick);
821
+ if (withPick && !withItem) {
822
+ return { error: `${at} anvil 的 withPick 是给 with 挑哪一件,要和 with 一起给` };
823
+ }
824
+ return {
825
+ step: {
826
+ skill: 'anvil', op: op as 'repair' | 'combine' | 'rename', item,
827
+ ...(withItem ? { with: withItem } : {}),
828
+ ...(name ? { name } : {}),
829
+ ...(anvilAt ? { at: anvilAt } : {}),
830
+ ...(pick ? { pick } : {}),
831
+ ...(withPick ? { withPick } : {}),
832
+ },
833
+ };
834
+ }
835
+
836
+ function parseGrindstone(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
837
+ const item = str(c.item);
838
+ if (!item) return { error: `${at} grindstone 要 item(要磨的那件东西的物品英文 id)` };
839
+ const withItem = str(c.with);
840
+ const gsAt = c.at === undefined || c.at === null ? null : anchorOf(c.at, marks);
841
+ if (c.at !== undefined && c.at !== null && !gsAt) {
842
+ return { error: `${at} grindstone 的 at 要 [x,y,z](${RELATIVE_HINT})${markMissNote(c.at, marks)}` };
843
+ }
844
+ const pick = str(c.pick);
845
+ const withPick = str(c.withPick);
846
+ if (withPick && !withItem) {
847
+ return { error: `${at} grindstone 的 withPick 是给 with 挑哪一件,要和 with 一起给` };
848
+ }
849
+ return {
850
+ step: {
851
+ skill: 'grindstone', item,
852
+ ...(withItem ? { with: withItem } : {}),
853
+ ...(gsAt ? { at: gsAt } : {}),
854
+ ...(pick ? { pick } : {}),
855
+ ...(withPick ? { withPick } : {}),
856
+ },
857
+ };
858
+ }
859
+
860
+ /** `take` 的两种操作必须显式区分:定量取物,或清空指定容器。 */
861
+ function parseTake(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
862
+ const item = str(c.item);
863
+ const takeAt = c.at === undefined || c.at === null ? null : anchorOf(c.at, marks);
864
+ if (c.at !== undefined && c.at !== null && !takeAt) {
865
+ return { error: `${at} take 的 at 要 [x,y,z](${RELATIVE_HINT})${markMissNote(c.at, marks)}` };
866
+ }
867
+ const all = c.all === true;
868
+ if (c.all !== undefined && c.all !== true) {
869
+ return { error: `${at} take 的 all 只认 true;清空指定容器写 all:true` };
870
+ }
871
+ if (all) {
872
+ if (!takeAt) return { error: `${at} take 的 all:true 必须同时给 at:[x,y,z]` };
873
+ if (item || c.count !== undefined || c.pick !== undefined) {
874
+ return { error: `${at} take 的 all:true 是清空指定容器,不能再给 item、count 或 pick` };
875
+ }
876
+ return { step: { skill: 'take', at: takeAt, all: true } };
877
+ }
878
+ if (!item) {
879
+ return { error: takeAt
880
+ ? `${at} take 点名容器后还要说明操作:定量取物写 item+count;清空写 all:true`
881
+ : `${at} take 要 item+count(定量取物),或 at+all:true(清空指定容器)` };
882
+ }
883
+ if (c.count === undefined || c.count === null) {
884
+ return { error: `${at} take 定量取物必须写 count(1-64),不再默认成 1` };
885
+ }
886
+ const count = intIn(c.count, 1, 64, 1);
887
+ if (count === null) return { error: `${at} take 的 count 要在 1-64 之间` };
888
+ const pick = str(c.pick);
889
+ return {
890
+ step: {
891
+ skill: 'take',
892
+ item,
893
+ count,
894
+ ...(takeAt ? { at: takeAt } : {}),
895
+ ...(pick ? { pick } : {}),
896
+ },
897
+ };
898
+ }
899
+
900
+ /**
901
+ * enchant 的 `index` 有三态:不写 = 只看报价;1/2/3 = 按那一档下手。
902
+ * 表驱动的 int 字段必带默认值,而这里「没写」本身是一种语义,只能走钩子。
903
+ */
904
+ function parseEnchant(c: Record<string, unknown>, at: string, marks?: MarkLookup): ParseResult {
905
+ const table = anchorOf(c.at, marks);
906
+ if (!table) {
907
+ return { error: `${at} enchant 要 at:[x,y,z](${RELATIVE_HINT}),指着附魔台那一格${markMissNote(c.at, marks)}` };
908
+ }
909
+ const item = str(c.item);
910
+ if (!item) return { error: `${at} enchant 要 item(要给哪样东西附魔,英文 id)` };
911
+ if (c.index === undefined || c.index === null) return { step: { skill: 'enchant', at: table, item } };
912
+ const index = intIn(c.index, 1, 3, 1);
913
+ if (index === null) return { error: `${at} enchant 的 index 要 1/2/3(上中下三格),不写就是只看报价` };
914
+ return { step: { skill: 'enchant', at: table, item, index } };
915
+ }
916
+
917
+ function parseCraft(c: Record<string, unknown>, at: string): ParseResult {
918
+ const count = intIn(c.count, 1, 64, 1);
919
+ if (count === null) return { error: `${at} craft 的 count 要在 1-64 之间` };
920
+ if (c.grid !== undefined) {
921
+ const grid = gridOf(c.grid);
922
+ if (!grid) {
923
+ return {
924
+ error: `${at} craft 的 grid 要是按行写的名字二维数组(空位写 null),最多 3 行 3 列`,
925
+ };
926
+ }
927
+ // 格子摆好了就按格子做,产出槽出什么算什么;同时写的 item 没有用武之地
928
+ const spare = str(c.item);
929
+ return {
930
+ step: { skill: 'craft', grid, count },
931
+ ...(spare
932
+ ? { notes: [{ field: 'item', given: c.item, kind: 'dropped' as const, why: 'grid 在场,按格子做' }] }
933
+ : {}),
934
+ };
935
+ }
936
+ const item = str(c.item);
937
+ if (!item) return { error: `${at} craft 要 item(物品英文 id)或 grid(自己摆的格子)` };
938
+ return { step: { skill: 'craft', item, count } };
939
+ }
940
+
941
+ /** 锚点的 schema 片段([x,y,z],分量数字或 "~" 相对写法;整个给一个字符串 = mc_map 路标名) */
942
+ const ANCHOR_SCHEMA = {
943
+ type: ['array', 'string'], items: { type: ['number', 'string'] }, minItems: 3, maxItems: 3,
944
+ } as const;
945
+
946
+ /** 注册表本体。顺序即 SKILL_NAMES 与 SKILL_DOC 的出场顺序 */
947
+ const SKILLS: readonly SkillSpec[] = [
948
+ {
949
+ name: 'goto',
950
+ doc: `{"skill":"goto","at":[100,-20],"dimension":"overworld"} 去坐标。dimension 是当前维度前置条件,不符就不动。
951
+ **只写 [x,z] = 按那儿的地表落脚**(区块没加载会照实受阻)。
952
+ at 也收 mc_map 的路标名:{"skill":"goto","at":"家"}。
953
+ 赶路会自己挖方块/搭方块开路`,
954
+ parse: parseGoto,
955
+ fields: [
956
+ {
957
+ key: 'at', kind: 'anchor', required: true,
958
+ error: `goto 要 at:[x,y,z] / [x,z](${RELATIVE_HINT}),或一个 mc_map 路标名`,
959
+ schema: { type: ['array', 'string'], items: { type: ['number', 'string'] }, minItems: 2, maxItems: 3 },
960
+ doc: '只写 [x,z] = 按那儿的地表落脚;给字符串 = mc_map 路标名',
961
+ },
962
+ { key: 'dimension', kind: 'string', doc: '可选的当前维度前置条件(overworld/the_nether/the_end)' },
963
+ { key: 'dryRun', kind: 'flag' },
964
+ ],
965
+ },
966
+ {
967
+ name: 'transit',
968
+ doc: `{"skill":"transit","at":[-228,73,58]} 穿过这一格的下界传送门。只认当前维度里已加载的 nether_portal;
969
+ 会先走到门边,再明确踏进门里,等维度和落点都切换后才算完成`,
970
+ fields: [
971
+ {
972
+ key: 'at', kind: 'anchor', required: true,
973
+ error: `transit 要 at:[x,y,z](下界传送门方块;${RELATIVE_HINT}),或一个当前维度的 mc_map 路标名`,
974
+ doc: '当前维度里的一格 nether_portal;给字符串 = 当前维度的 mc_map 路标名',
975
+ },
976
+ ],
977
+ },
978
+ {
979
+ name: 'goto_player',
980
+ doc: '{"skill":"goto_player","name":"Alice"} 去某玩家身边',
981
+ fields: [{ key: 'name', kind: 'string', required: true, hint: '在线玩家名' }],
982
+ },
983
+ {
984
+ name: 'follow',
985
+ doc: '{"skill":"follow","name":"Alice"} 持续跟随,直到被新任务顶替',
986
+ fields: [{ key: 'name', kind: 'string', required: true, hint: '在线玩家名' }],
987
+ },
988
+ {
989
+ name: 'find',
990
+ doc: `{"skill":"find","target":"chest","distance":32} 找东西,方块和活物都认,只报看得见的(隔玻璃算看得见)。
991
+ 不给 direction = 站着扫一圈,最远 ${FIND_STATIC_MAX} 格,不挪地方。
992
+ 回执里的 blockAt 是目标方块占用格,不是可站落点;seenAt 是活物被看见那一刻的位置。
993
+ 当前没看见只说明当前观察面没命中,不表示目标不存在。旧位置只在真实看见过时出现,并带年龄。
994
+ 给了 direction = 朝那个方向边走边找,看见就停下报坐标;走满没看见也算做完。
995
+ 若出发点全向扫描先命中,回执会明确请求方向尚未搜索,不能把命中叫作该方向的结果
996
+ {"skill":"find","target":"cow","distance":64,"direction":"east","until":["#ores","water"]}
997
+ 边走边找时加 "until" = 路上看见这里头任何一样就停下来报坐标(算做完)。
998
+ 名单写方块英文 id,也认类别 ${UNTIL_CATEGORY_DOC};站着扫用不上它`,
999
+ fields: [
1000
+ { key: 'target', kind: 'string', required: true, hint: '要找的方块或活物英文 id', doc: '方块或实体英文 id' },
1001
+ {
1002
+ key: 'direction', kind: 'enum', values: Object.keys(DIRECTIONS),
1003
+ error: `find 的 direction 要是 ${Object.keys(DIRECTIONS).join('/')} 之一`,
1004
+ doc: '给了就边走边找;不给就站着扫',
1005
+ },
1006
+ { key: 'distance', kind: 'int', lo: 1, hi: 1024, def: FIND_STATIC_MAX, unit: '格' },
1007
+ {
1008
+ key: 'until', kind: 'names', hint: `方块英文 id 或类别 ${UNTIL_CATEGORY_DOC}`,
1009
+ doc: '边走边找时:路上碰到这里头任何一样就停',
1010
+ },
1011
+ ],
1012
+ },
1013
+ {
1014
+ name: 'flee',
1015
+ doc: '{"skill":"flee","distance":24} 远离最近的敌对生物',
1016
+ fields: [{ key: 'distance', kind: 'int', lo: 1, hi: 128, def: 24, unit: '格' }],
1017
+ },
1018
+ {
1019
+ name: 'surface',
1020
+ doc: `{"skill":"surface"} 脱离水体或向上到露天:在水里=浮上水面并站到干燥落脚格,回执另报 sky_visible;
1021
+ 只换到气、没找到岸或游不到岸都算没完成;在陆上=挖+垫一路上行到露天`,
1022
+ fields: [],
1023
+ },
1024
+ {
1025
+ name: 'collect',
1026
+ doc: `{"skill":"collect","block":"oak_log","count":3} 采集方块,只挖看得见的——埋在石头里的看不见,得先挖开或者找暴露的。
1027
+ 加 "buried":true = 看得见但走不过去时,允许挖条路过去(最多 4 次)。
1028
+ 加 "mature":true = 作物只收 age 到顶的,没长成的留着。
1029
+ tool 不写=节约耐久;"fastest"=本步最快;物品 id=本步精确指定,都不改长期设置`,
1030
+ fields: [
1031
+ { key: 'block', kind: 'string', required: true, hint: '方块英文 id' },
1032
+ { key: 'count', kind: 'int', lo: 1, hi: 64, def: 1 },
1033
+ { key: 'buried', kind: 'flag' },
1034
+ { key: 'mature', kind: 'flag' },
1035
+ { key: 'tool', kind: 'string', hint: '物品英文 id 或 fastest', doc: '只覆盖本步;不写时节约耐久' },
1036
+ ],
1037
+ },
1038
+ {
1039
+ name: 'fish',
1040
+ doc: `{"skill":"fish"} 钓一竿:12 格内的水面里优先选开阔水域(周围 5×5 至少 2 格深、离岸 ≥3 格,宝藏只在这种水里出),没有才钓岸边并说明;
1041
+ 站在原地抛得到就不挪窝。45 秒没上钩就收竿(浮标头顶看不到天时 60 秒);
1042
+ 加 "at":[x,y,z] = 指定钓哪格水面。抛竿角度按弹道自己算,浮标没落进水里会换角度重抛`,
1043
+ fields: [
1044
+ { key: 'at', kind: 'anchor', error: `fish 的 at 要 [x,y,z](水面那一格;${RELATIVE_HINT})` },
1045
+ ],
1046
+ },
1047
+ {
1048
+ name: 'tunnel',
1049
+ doc: `{"skill":"tunnel","at":["~","~-10","~30"]} 从脚下朝那一格挖一条 1 宽 2 高的通道,斜着自动成楼梯;
1050
+ 坡度超 45° 不接。走到终点才算挖通,没走到就报断在哪一格。
1051
+ {"skill":"tunnel","at":["~","~-30","~"]} 终点在正下方 = 竖井,一格一格往下挖(下面塌空就停,不往下掉;竖井是单程的,回不来)
1052
+ {"skill":"tunnel","at":["~","~12","~"]} 终点在正上方 = 塔,一格一格垫上去(垫脚料见 mc_policy)
1053
+ {"skill":"tunnel","at":["~","~-30","~"],"spiral":true}
1054
+ 正上/正下 + "spiral" = 螺旋楼梯:绕脚下 2×2 井筒边挖边转,每格升降 1,
1055
+ 挖完上下都能走——下矿要能自己走回来就用这个,别用竖井
1056
+ {"skill":"tunnel","at":["~","~-30","~"],"until":["#ores"]}
1057
+ 加 "until" = 挖的路上周围碰到这里头任何一样就停下来报坐标(算做完,
1058
+ 不再往终点挖)。名单写方块英文 id,也认类别 ${UNTIL_CATEGORY_DOC}。
1059
+ tool 不写=节约耐久;"fastest"=本步最快;物品 id=本步精确指定`,
1060
+ fields: [
1061
+ { key: 'at', kind: 'anchor', required: true, error: `tunnel 要 at:[x,y,z](通道终点;${RELATIVE_HINT})`, doc: '通道终点' },
1062
+ { key: 'spiral', kind: 'flag', doc: '正上/正下时改挖 2×2 螺旋楼梯(上下都能走)' },
1063
+ { key: 'dryRun', kind: 'flag' },
1064
+ { key: 'tool', kind: 'string', hint: '物品英文 id 或 fastest', doc: '只覆盖本步;不写时节约耐久' },
1065
+ {
1066
+ key: 'until', kind: 'names', hint: `方块英文 id 或类别 ${UNTIL_CATEGORY_DOC}`,
1067
+ doc: '挖的路上碰到这里头任何一样就停',
1068
+ },
1069
+ ],
1070
+ },
1071
+ {
1072
+ name: 'build',
1073
+ doc: `{"skill":"build","material":"torch","on":[{"at":[103,63,-31],"face":"up"}]}
1074
+ 贴着 at 那一格的 face 面放,新方块落在那一面的外侧;
1075
+ face 认 ${FACE_NAMES.join('/')},一步最多 ${PLACE_SPOTS_MAX} 处
1076
+ {"skill":"build","material":"torch","anchors":[[103,64,-31]]}
1077
+ 直接点格子:**不写 shape = 就这些格**(1-${PLACE_SPOTS_MAX} 个),贴哪一面由我挑;
1078
+ 那一格已经是这个方块就直接算做好。
1079
+ 给 shape + 多锚点就按形状连片搭:line 2 个、rect 2 个(轴对齐的面,
1080
+ 两锚点须有一轴相等)、triangle 3 个、arc 3 个(过三点的弧)、
1081
+ box 2 个(对角,fill: solid 实心/outline 只有外壳/edges 只有 12 条棱;
1082
+ outline 不清内部,原版 /fill 的 hollow 那个模式这里没有)。
1083
+ 一单最多 256 块;材料用完或贴不住就停在那。
1084
+ dryRun 试算:要动几块、材料够不够
1085
+ {"skill":"build","blueprint":"home-v2","at":[100,64,-30]}
1086
+ 按 mc_blueprint 装载着的那张图施工。**at 是蓝图 [0,0,0] 落在世界的哪一格**,
1087
+ 第一次要给,续建省略(接着上次那个锚点往下施工)。
1088
+ 开工前先跟世界对一遍账:已经对上的格子跳过,只做差的那些;
1089
+ 料用完就停在那儿。完成(或阶段停)会逐格回读验收。
1090
+ 加 "stopAfter":2 = 施工到第 2 层(y 层号,0 是最低层)就收工;
1091
+ retrofit 初探报出冲突后,审阅无误可加 "confirm":true 清掉那些冲突格再施工;
1092
+ 或加 "skipConflicts":true 把冲突格原样留着、先放能放的(回执点名跳过了哪几格);
1093
+ dryRun 试算:冲突格、在箱/随身/还缺三分账单、手上的料能连着施工到第几步`,
1094
+ parse: parseShaped('build'),
1095
+ fields: [
1096
+ {
1097
+ key: 'blueprint', kind: 'string',
1098
+ hint: 'mc_blueprint 装载着的键', doc: '按蓝图施工;形状与材料都在图里',
1099
+ },
1100
+ {
1101
+ key: 'stopAfter', kind: 'int', lo: 0, hi: Number.MAX_SAFE_INTEGER, def: 0,
1102
+ doc: '按蓝图施工时:做到这个 y 层就收工(0 是最低层)',
1103
+ },
1104
+ { key: 'confirm', kind: 'flag', doc: 'retrofit 有冲突时:确认清掉冲突格后施工' },
1105
+ { key: 'skipConflicts', kind: 'flag', doc: 'retrofit 有冲突时:留着冲突格,先放能放的' },
1106
+ {
1107
+ key: 'shape', kind: 'enum', values: SHAPE_NAMES,
1108
+ error: `build 的 shape 要是 ${SHAPE_NAMES.join('/')} 之一`, doc: '不写 = 就 anchors 那些格',
1109
+ },
1110
+ {
1111
+ key: 'on', kind: 'opaque',
1112
+ schema: {
1113
+ type: 'array',
1114
+ items: {
1115
+ type: 'object',
1116
+ properties: { at: { ...ANCHOR_SCHEMA }, face: { type: 'string', enum: [...FACE_NAMES] } },
1117
+ required: ['at', 'face'],
1118
+ },
1119
+ minItems: 1, maxItems: PLACE_SPOTS_MAX,
1120
+ },
1121
+ doc: '贴着 at 那一格的 face 面放;与 anchors 二选一',
1122
+ },
1123
+ {
1124
+ key: 'anchors', kind: 'opaque',
1125
+ schema: { type: 'array', items: { ...ANCHOR_SCHEMA }, minItems: 1, maxItems: PLACE_SPOTS_MAX },
1126
+ doc: '不写 shape = 就这些格;与 on 二选一',
1127
+ },
1128
+ // 三形态里两形态必填;蓝图形态一个都不收,由 parse 钩子裁决(required 只进文档)
1129
+ { key: 'material', kind: 'string', required: true, hint: '方块英文 id' },
1130
+ {
1131
+ key: 'at', kind: 'anchor',
1132
+ error: `build 的 at 要 [x,y,z](蓝图 [0,0,0] 落在世界的哪一格;${RELATIVE_HINT})`,
1133
+ doc: '按蓝图施工时:蓝图 [0,0,0] 落在世界的哪一格',
1134
+ },
1135
+ { key: 'fill', kind: 'enum', values: ['solid', 'outline', 'edges'], error: 'fill 只认 solid/outline/edges', doc: '只对 box 有意义' },
1136
+ { key: 'dryRun', kind: 'flag' },
1137
+ ],
1138
+ },
1139
+ {
1140
+ name: 'excavate',
1141
+ doc: `{"skill":"excavate","shape":"box","anchors":[[160,-58,95],[168,-52,102]]}
1142
+ 按形状挖空间,shape/锚点同 build。一单最多 512 块;
1143
+ 紧贴液体的格子不挖。tool 不写=节约耐久;"fastest"=本步最快;
1144
+ 物品 id=本步精确指定,都不改长期设置`,
1145
+ parse: parseShaped('excavate'),
1146
+ fields: [
1147
+ {
1148
+ key: 'shape', kind: 'enum', values: SHAPE_NAMES, required: true,
1149
+ error: `excavate 的 shape 要是 ${SHAPE_NAMES.join('/')} 之一`,
1150
+ },
1151
+ {
1152
+ key: 'anchors', kind: 'opaque', required: true,
1153
+ schema: { type: 'array', items: { ...ANCHOR_SCHEMA }, minItems: 2, maxItems: 3 },
1154
+ doc: '数量由 shape 定',
1155
+ },
1156
+ { key: 'fill', kind: 'enum', values: ['solid', 'outline', 'edges'], error: 'fill 只认 solid/outline/edges' },
1157
+ { key: 'dryRun', kind: 'flag' },
1158
+ { key: 'tool', kind: 'string', hint: '物品英文 id 或 fastest', doc: '只覆盖本步;不写时节约耐久' },
1159
+ ],
1160
+ },
1161
+ {
1162
+ name: 'probe',
1163
+ doc: `{"skill":"probe","shape":"line","anchors":[["~","~2","~"],["~","~80","~"]]}
1164
+ 只看不动:读出你圈的这片形状里的材质构成与液体;27 格以内
1165
+ 逐格报「(x,y,z):方块」,作物带 age。圈哪片由你定
1166
+ {"skill":"probe","shape":"box","anchors":[[-40,40,-120],[-8,60,-88]],"where":["spawner","#chests"]}
1167
+ 加 "where" = 只报这几样在这片里的坐标(按远近,每样最多 ${PROBE_WHERE_SHOWN} 处)。
1168
+ 这一档直接读区块,不看视线也不管挡没挡着 —— 封在结构里的刷怪笼、
1169
+ 埋着的箱子、矿脉走这条;find 只看得见明面上的东西。名单认 ${UNTIL_CATEGORY_DOC}`,
1170
+ parse: parseShaped('probe'),
1171
+ fields: [
1172
+ {
1173
+ key: 'shape', kind: 'enum', values: SHAPE_NAMES, required: true,
1174
+ error: `probe 的 shape 要是 ${SHAPE_NAMES.join('/')} 之一`,
1175
+ },
1176
+ {
1177
+ key: 'anchors', kind: 'opaque', required: true,
1178
+ schema: { type: 'array', items: { ...ANCHOR_SCHEMA }, minItems: 2, maxItems: 3 },
1179
+ doc: '数量由 shape 定',
1180
+ },
1181
+ { key: 'fill', kind: 'enum', values: ['solid', 'outline', 'edges'], error: 'fill 只认 solid/outline/edges', doc: '只对 box 有意义' },
1182
+ {
1183
+ key: 'where', kind: 'names', hint: `方块英文 id 或类别 ${UNTIL_CATEGORY_DOC}`,
1184
+ doc: '只报这几样在这片里的坐标(不看视线)',
1185
+ },
1186
+ ],
1187
+ },
1188
+ {
1189
+ name: 'use',
1190
+ doc: `{"skill":"use","item":"flint_and_steel","at":[103,64,-31]}
1191
+ 右键那一格:开门/拉杆/按钮、空桶装水、锄头翻地、骨粉催熟、
1192
+ 开箱子看一眼、点床睡觉。不写 item = 用手上现在拿着的。
1193
+ 加 "times":5 = 连着右键 5 次(最多 16)。
1194
+ 想让空着的那一格出现东西(放方块/床/船)用 build
1195
+ {"skill":"use","item":"water_bucket","at":[-185,70,61]}
1196
+ 满桶倒出去:水/岩浆浇在那一格,倒完包里多一个空桶。
1197
+ 倒水走这条,build 的 material 不认水桶
1198
+ {"skill":"use","item":"shears","target":"sheep"} 右键活物:剪毛、挤奶、喂食、上鞍。
1199
+ target 写 villager/wandering_trader = 看报价菜单(只看不买);
1200
+ 再带 "index":1,"times":2 = 按菜单 1 号成交 2 次
1201
+ {"skill":"use","item":"potion"} 只给 item:对自己/面前用,喝药水、拉弓蓄力。
1202
+ 投掷类(喷溅药水、末影珍珠、雪球、鸡蛋)再给 at = 朝那一格扔
1203
+ {"skill":"use","at":[-147,72,101],"text":"欢迎来我家\\n可缇"}
1204
+ **往告示牌上写字**:先 build 把牌子放上,再用这条写。
1205
+ \\n 分行,最多 4 行、每行 45 字符;写完读回牌子上的字进回执。
1206
+ 手上拿着染料/墨囊/蜂巢时右键做的是改色/发光/上蜡,先空手
1207
+ {"skill":"use","item":"item_frame","at":[-147,72,101],"face":"north"}
1208
+ face = 右键那一格的哪一面(缺省顶面)。画只能贴侧面`,
1209
+ parse: parseUse,
1210
+ fields: [
1211
+ { key: 'item', kind: 'string', doc: '不写 = 用手上现在拿着的' },
1212
+ { key: 'at', kind: 'anchor', error: `use 的 at 要 [x,y,z](${RELATIVE_HINT})`, doc: '右键那一格;与 target 二选一' },
1213
+ { key: 'target', kind: 'string', doc: '右键活物;与 at 二选一' },
1214
+ { key: 'index', kind: 'int', lo: 1, hi: 99, def: 1 },
1215
+ { key: 'times', kind: 'int', lo: 1, hi: USE_TIMES_MAX, def: 1, doc: '连着右键几次;带 index 时是成交几次' },
1216
+ { key: 'text', kind: 'string', doc: `写在告示牌上的字,\\n 分行;最多 ${SIGN_LINES} 行、每行 ${SIGN_LINE_CHARS} 字符` },
1217
+ { key: 'back', kind: 'flag', doc: '写在牌子背面;缺省正面' },
1218
+ { key: 'face', kind: 'enum', values: FACE_NAMES, error: `use 的 face 要是 ${FACE_NAMES.join('/')} 之一`, doc: '右键那一格的哪一面;缺省顶面' },
1219
+ ],
1220
+ },
1221
+ {
1222
+ name: 'ride',
1223
+ doc: `{"skill":"ride","target":"pig","to":[120,64,-30]}
1224
+ 骑上坐骑并驾着走。**能驾的只有猪(要先上鞍、包里有胡萝卜钓竿)
1225
+ 和船**;马/驴骑得上但驾不了(这版不支持),骑上不动。
1226
+ to 只认水平目的地,到目标 2.5 格内算到;20 秒零推进会自己下来并报走到哪。
1227
+ {"skill":"ride","target":"boat"} 只骑上不走;之后驾驭再来一步 {"skill":"ride","to":[x,y,z]}
1228
+ {"skill":"ride","off":true} 从坐骑上下来。骑着的时候寻路器不管坐骑,goto 走不了`,
1229
+ parse: parseRide,
1230
+ fields: [
1231
+ { key: 'target', kind: 'string', doc: '骑上哪种(实体英文 id:pig/boat/horse…);已骑着时可省' },
1232
+ { key: 'to', kind: 'anchor', error: `ride 的 to 要 [x,y,z](${RELATIVE_HINT})`, doc: '驾着去哪;只有猪和船能驾' },
1233
+ { key: 'off', kind: 'flag', doc: '下坐骑;与 target/to 互斥' },
1234
+ ],
1235
+ },
1236
+ {
1237
+ name: 'lead',
1238
+ doc: `{"skill":"lead","target":"cow","to":[-146,71,103],"tolerance":3}
1239
+ **拴绳牵动物**:包里要有拴绳(lead)。走到最近的那只跟前拴上,
1240
+ 分段牵着走到 to,到了就松开,它留在那儿。
1241
+ tolerance = 牵到 to 多少格内算到(缺省 3;绳是软的,拉不到落脚点上)。
1242
+ 路上它掉队会停下等;绳绷断(超 10 格)当场停下报断在哪儿。
1243
+ 拴不上的:村民、幼崽以外的敌对生物、别人已经牵着的
1244
+ {"skill":"lead","target":"cow","to":[...],"tie":[-145,67,103]}
1245
+ 牵到了再**系到 tie 那一格的栅栏上**——这样它才真的圈住了,
1246
+ 松开只是它暂时站那儿
1247
+ {"skill":"lead","target":"cow"} 只拴上不牵走(之后再来一步给 to)
1248
+ {"skill":"lead","to":[...],"keep":true} 把现在牵着的牵过去,到了继续牵着不松
1249
+ {"skill":"lead","off":true} 松开现在牵着的那只(绳掉在地上,记得 pickup)`,
1250
+ parse: parseLead,
1251
+ fields: [
1252
+ { key: 'target', kind: 'string', doc: '拴哪种(实体英文 id);已经牵着时可省' },
1253
+ { key: 'to', kind: 'anchor', error: `lead 的 to 要 [x,y,z](${RELATIVE_HINT})`, doc: '牵去哪' },
1254
+ {
1255
+ key: 'tolerance', kind: 'int', lo: LEAD_TOL.lo, hi: LEAD_TOL.hi, def: LEAD_TOL.def, unit: '格',
1256
+ doc: '牵到 to 多少格内算到',
1257
+ },
1258
+ { key: 'tie', kind: 'anchor', error: `lead 的 tie 要 [x,y,z](${RELATIVE_HINT})`, doc: '到了系在这一格的栅栏上' },
1259
+ { key: 'keep', kind: 'flag', doc: '到了继续牵着,不松开' },
1260
+ { key: 'off', kind: 'flag', doc: '松开;与 target/to/tie/keep 互斥' },
1261
+ ],
1262
+ },
1263
+ {
1264
+ name: 'craft',
1265
+ doc: `{"skill":"craft","grid":[["charcoal"],["stick"]],"count":4}
1266
+ **自己摆合成格**:grid 是按行写的名字二维数组,空位写 null。
1267
+ 2 行 2 列以内徒手就能做,更大要工作台。产出槽出什么算什么。
1268
+ {"skill":"craft","item":"wooden_pickaxe","count":1}
1269
+ 也可以只写 item,按游戏自带的配方表摆。**中间材料不会自动补**。
1270
+ 够得着的工作台直接用;够不着就放一个自己带的,包里没有才走去现成的`,
1271
+ parse: parseCraft,
1272
+ fields: [
1273
+ { key: 'item', kind: 'string', doc: '按游戏配方表摆;与 grid 二选一' },
1274
+ {
1275
+ key: 'grid', kind: 'opaque',
1276
+ schema: { type: 'array', items: { type: 'array', items: { type: ['string', 'null'] } } },
1277
+ doc: '与 item 二选一',
1278
+ },
1279
+ { key: 'count', kind: 'int', lo: 1, hi: 64, def: 1 },
1280
+ ],
1281
+ },
1282
+ {
1283
+ name: 'smelt',
1284
+ doc: `{"skill":"smelt","input":"raw_iron","count":8,"fuel":"charcoal"}
1285
+ 走到炉边下料点火就走,input 与 fuel 都必写。炉子自己烧
1286
+ (熔炉一件约 10 秒),烧好会有事件提醒;取货用 take 的 at 指着炉子。
1287
+ 够得着的炉子直接用;够不着就放一个自己带的,包里没有才走去现成的。
1288
+ 还烧着别的东西的炉子不挑,几座炉子可以同时各烧各的;
1289
+ 加 "at":[x,y,z] = 指定用那一座炉子`,
1290
+ fields: [
1291
+ { key: 'input', kind: 'string', required: true, hint: '原料英文 id' },
1292
+ { key: 'count', kind: 'int', lo: 1, hi: 64, def: 1 },
1293
+ { key: 'fuel', kind: 'string', required: true, hint: '燃料英文 id' },
1294
+ { key: 'at', kind: 'anchor', error: `smelt 的 at 要 [x,y,z](${RELATIVE_HINT})`, doc: '指定用哪一座炉子' },
1295
+ ],
1296
+ },
1297
+ {
1298
+ name: 'brew',
1299
+ doc: `{"skill":"brew","at":[x,y,z],"input":"nether_wart","bottle":"potion","count":3,"fuel":"blaze_powder"}
1300
+ 下料点火就走,酿一轮约 20 秒,好了有事件提醒;取货用 take 的 at 指着酿造台。
1301
+ **水瓶与所有药水的物品 id 都是 potion**,名字上分不出来;回执带的「内容 #N」
1302
+ 是原版药水注册表序号,同一种药水这个数不变,拿它对账。
1303
+ 燃料只吃烈焰粉(一份烧 20 轮),煤不行。
1304
+ 材料链三段跳不过:水瓶 →(地狱疣)→ 粗制药水 →(效果材料)→ 基础药水
1305
+ →(红石延时 / 萤石粉加强 / 火药变喷溅 / 龙息变滞留)。
1306
+ 红石与萤石粉互斥,同一瓶不能既加强又延时。
1307
+ 玻璃瓶装水:{"skill":"use","item":"glass_bottle","at":水源那一格}`,
1308
+ fields: [
1309
+ { key: 'input', kind: 'string', required: true, hint: '这一轮加的材料英文 id' },
1310
+ { key: 'bottle', kind: 'string', required: true, hint: '瓶子英文 id(potion / glass_bottle)' },
1311
+ { key: 'count', kind: 'int', lo: 1, hi: 3, def: 3, doc: '放几瓶(三个瓶位)' },
1312
+ { key: 'fuel', kind: 'string', required: true, hint: '燃料英文 id(原版只吃 blaze_powder)' },
1313
+ { key: 'at', kind: 'anchor', error: `brew 的 at 要 [x,y,z](${RELATIVE_HINT})`, doc: '指定用哪一座酿造台' },
1314
+ ],
1315
+ },
1316
+ {
1317
+ name: 'enchant',
1318
+ doc: `{"skill":"enchant","at":[x,y,z],"item":"diamond_pickaxe"}
1319
+ 走到附魔台边,把东西放上去**只看三档报价,不下手**。
1320
+ 回执报三档各要多少级、多少青金石、显示出来的那条附魔,以及周围有效书架。
1321
+ {"skill":"enchant","at":[x,y,z],"item":"diamond_pickaxe","index":3}
1322
+ 按第 N 档下手(1/2/3 = 上中下三格)。
1323
+ 「需 N 级」是**门槛**;真扣掉的是档位号那么多级与同样多的青金石(1/2/3)。
1324
+ 原版下手前每档只显示一条附魔,别的要下手才知道。
1325
+ 已经附过魔的东西附魔台不接;书架要隔一格、同高或高一格,中间那格必须空着`,
1326
+ parse: parseEnchant,
1327
+ fields: [
1328
+ {
1329
+ key: 'at', kind: 'anchor', required: true,
1330
+ error: `enchant 要 at:[x,y,z](${RELATIVE_HINT}),指着附魔台那一格`,
1331
+ },
1332
+ { key: 'item', kind: 'string', required: true, hint: '要附魔的物品英文 id' },
1333
+ { key: 'index', kind: 'int', lo: 1, hi: 3, def: 1, doc: '不写 = 只看报价;写了 = 按那一档下手' },
1334
+ ],
1335
+ },
1336
+ {
1337
+ name: 'anvil',
1338
+ doc: `{"skill":"anvil","op":"combine","item":"iron_pickaxe","with":"iron_pickaxe"}
1339
+ 铁砧:两件同种合修(耐久相加+12%),或本体+附魔书。
1340
+ op:"repair" 与 combine 同义。**花的是经验等级**,回执报实扣几级;
1341
+ 等级不够时产出拿不走,回执会说门槛。铁砧每用一次有 12% 概率磨损一级
1342
+ {"skill":"anvil","op":"rename","item":"iron_sword","name":"新名字"}
1343
+ 改名(最多 ${ANVIL_NAME_MAX} 字符)。at 不写就用附近 16 格内的铁砧
1344
+ {"skill":"anvil","op":"combine","item":"bow","with":"enchanted_book","withPick":"无限"}
1345
+ 包里有好几本附魔书(同一个 id)时,靠 pick/withPick 点名要哪一本:
1346
+ 写清单括号里的字。不点名 = 撞上哪件算哪件`,
1347
+ parse: parseAnvil,
1348
+ fields: [
1349
+ { key: 'op', kind: 'enum', values: ['repair', 'combine', 'rename'], required: true, error: 'anvil 的 op 只认 repair/combine/rename' },
1350
+ { key: 'item', kind: 'string', required: true, hint: '左格物品英文 id' },
1351
+ { key: 'with', kind: 'string', doc: 'repair/combine 必写:右格放什么' },
1352
+ { key: 'name', kind: 'string', doc: 'rename 必写:新名字' },
1353
+ // 挑选词的说明只挂第一处:字段池按 (技能, doc) 逐条印,六个技能各写一遍就是印六遍
1354
+ { key: 'pick', kind: 'string', doc: PICK_DOC },
1355
+ { key: 'withPick', kind: 'string', doc: '同 pick,筛的是 with 那一格' },
1356
+ { key: 'at', kind: 'anchor', error: `anvil 的 at 要 [x,y,z](${RELATIVE_HINT})`, doc: '指定用哪一座铁砧;不写用最近的' },
1357
+ ],
1358
+ },
1359
+ {
1360
+ name: 'grindstone',
1361
+ doc: `{"skill":"grindstone","item":"iron_sword"} 砂轮:磨掉普通附魔并按原版比例**返还经验**(诅咒磨不掉);
1362
+ 再给 with(同种工具)= 两件合修成一件。回执报磨前磨后的耐久与附魔、
1363
+ 返了多少点经验。at 不写就用附近 16 格内的砂轮`,
1364
+ parse: parseGrindstone,
1365
+ fields: [
1366
+ { key: 'item', kind: 'string', required: true, hint: '要磨的物品英文 id' },
1367
+ { key: 'with', kind: 'string', doc: '合修:同种的另一件' },
1368
+ { key: 'pick', kind: 'string' },
1369
+ { key: 'withPick', kind: 'string' },
1370
+ { key: 'at', kind: 'anchor', error: `grindstone 的 at 要 [x,y,z](${RELATIVE_HINT})`, doc: '指定用哪一座砂轮;不写用最近的' },
1371
+ ],
1372
+ },
1373
+ {
1374
+ name: 'eat',
1375
+ doc: `{"skill":"eat","item":"bread"} 吃点名的食物;item 必须写完整英文 id。
1376
+ 河豚/蜘蛛眼/毒马铃薯会中毒:第一次只回后果不吃,3 分钟内再下一模一样的单才吃。
1377
+ milk_bucket 也走这条:喝掉清光身上的状态效果,不管饱,剩个空桶`,
1378
+ fields: [{ key: 'item', kind: 'string', required: true, hint: '食物英文 id(牛奶桶写 milk_bucket)' }],
1379
+ },
1380
+ {
1381
+ name: 'attack',
1382
+ doc: `{"skill":"attack","target":"zombie"} 攻击最近的该目标。mode 不写/auto = 距离判断近战或弓(8 格切弓、5.5 格切回近战);
1383
+ melee = 只近战;ranged = 只用弓;kite = 用弓并尽量保持 8–14 格。
1384
+ ranged/kite 没有可用弓箭或看不见目标时会受阻,不会暗换近战`,
1385
+ fields: [
1386
+ { key: 'target', kind: 'string', required: true, hint: '实体英文 id 或玩家名' },
1387
+ {
1388
+ key: 'mode', kind: 'enum', values: ['auto', 'melee', 'ranged', 'kite'],
1389
+ error: 'attack 的 mode 只认 auto/melee/ranged/kite',
1390
+ doc: '不写 = auto',
1391
+ },
1392
+ ],
1393
+ },
1394
+ {
1395
+ name: 'equip',
1396
+ doc: `{"skill":"equip","item":"stone_sword"} 手持物品;盔甲、鞘翅、盾牌会自动穿进对应装备槽。
1397
+ 不写 item = 把主手腾空(骑马、上鞍这类要空手的动作用它)`,
1398
+ parse: parseEquip,
1399
+ fields: [
1400
+ { key: 'item', kind: 'string', hint: '物品英文 id', doc: '不写 = 把主手腾空' },
1401
+ { key: 'pick', kind: 'string' },
1402
+ ],
1403
+ },
1404
+ {
1405
+ name: 'pickup',
1406
+ doc: '{"skill":"pickup","item":"cobblestone"} 拾取附近掉落物;item 可选,不写就近扫',
1407
+ fields: [{ key: 'item', kind: 'string', doc: '不写就近扫' }],
1408
+ },
1409
+ {
1410
+ name: 'toss',
1411
+ doc: `{"skill":"toss","item":"cobblestone","count":64} 扔掉。不写 at = 自己挑一个开阔方向抛出去,
1412
+ 免得东西落回脚边又被自己捡回来
1413
+ {"skill":"toss","item":"gold_ingot","at":[12,32,10]}
1414
+ **朝那一格扔**:以物易物把金锭扔到猪灵脚边、
1415
+ 把东西放到指定的地方。最远约 8 格,远了先走近。
1416
+ 抛物线的落点由服务端算,回执只说朝哪儿扔的`,
1417
+ fields: [
1418
+ { key: 'item', kind: 'string', required: true, hint: '物品英文 id' },
1419
+ { key: 'count', kind: 'int', lo: 1, hi: 64, def: 1 },
1420
+ {
1421
+ key: 'at', kind: 'anchor', error: `toss 的 at 要 [x,y,z](${RELATIVE_HINT})`,
1422
+ doc: '朝那一格扔;不写 = 自己挑一个开阔方向',
1423
+ },
1424
+ { key: 'pick', kind: 'string' },
1425
+ ],
1426
+ },
1427
+ {
1428
+ name: 'stow',
1429
+ doc: `{"skill":"stow","item":"cobblestone","count":64} 存进附近箱子(32 格内)。先找上次看见还有空位的,没有就开最近没开过的
1430
+ {"skill":"stow","item":"enchanted_book","pick":"精准采集","count":1}
1431
+ 同 id 的几件里只存点名的那件(留下别的)`,
1432
+ fields: [
1433
+ { key: 'item', kind: 'string', required: true, hint: '物品英文 id' },
1434
+ { key: 'count', kind: 'int', lo: 1, hi: 64, def: 1 },
1435
+ { key: 'pick', kind: 'string' },
1436
+ ],
1437
+ },
1438
+ {
1439
+ name: 'take',
1440
+ doc: `{"skill":"take","item":"coal","count":16} 从附近箱子取出。先找账本里有的,对不上再开,最多 3 个
1441
+ {"skill":"take","at":[103,63,-31],"all":true} 明确清空那一格容器:炉子=输出+没烧完的料+剩的燃料;箱子=整箱。
1442
+ 定量取物必须同时写 item 和 count,不默认数量`,
1443
+ parse: parseTake,
1444
+ fields: [
1445
+ { key: 'item', kind: 'string', hint: '物品英文 id', doc: '定量取物时与 count 一起写' },
1446
+ { key: 'count', kind: 'int', lo: 1, hi: 64, def: 1, doc: '定量取物必须显式写;不再默认成 1' },
1447
+ { key: 'at', kind: 'anchor', error: `take 的 at 要 [x,y,z](${RELATIVE_HINT})`, doc: '点名哪一格容器' },
1448
+ { key: 'all', kind: 'flag', doc: '只在 at+all:true 时清空指定容器' },
1449
+ { key: 'pick', kind: 'string' },
1450
+ ],
1451
+ },
1452
+ {
1453
+ name: 'chat',
1454
+ doc: '{"skill":"chat","text":"..."} 游戏内说话',
1455
+ fields: [{ key: 'text', kind: 'string', required: true }],
1456
+ },
1457
+ ];
1458
+
1459
+ const SKILL_INDEX = new Map(SKILLS.map((s) => [s.name as string, s]));
1460
+
1461
+ function specIsScout(s: SkillSpec): boolean {
1462
+ return s.name === 'probe' || s.fields.some((f) => f.key === 'dryRun');
1463
+ }
1464
+
1465
+ const SCOUT_SKILLS = SKILLS.filter(specIsScout);
1466
+
1467
+ /** 技能名清单:mc_do 参数 schema 的 enum;顺序即注册表出场顺序 */
1468
+ export const SKILL_NAMES: ReadonlyArray<SkillCall['skill']> = SKILLS.map((s) => s.name);
1469
+
1470
+ /** mc_scout 收的技能名;与 schema enum、parseScoutSteps 同源 */
1471
+ export const SCOUT_SKILL_NAMES: ReadonlyArray<SkillCall['skill']> = SCOUT_SKILLS.map((s) => s.name);
1472
+
1473
+ const SKILL_DOC_TAIL = `坐标写成 [x,y,z];at 是一个坐标,anchors 是一串坐标。
1474
+ 每个分量是绝对数字,或 "~"/"~-3" 相对写法——原点是**这一步开始执行那一刻**我脚下那一格,
1475
+ 所以 goto 之后接 "~" 指的是那个 goto 的落点。没有前后左右的写法。
1476
+ 回执一律报解析后的绝对坐标。
1477
+ 方块/物品/实体一律用英文 id(oak_log、cobblestone、skeleton);target 也收玩家名。
1478
+ 类别名有两套:target/block/material 认 log、planks、ore、wool 这样的裸名(它们本身不是物品 id),把所有同类算进去;
1479
+ until 名单认带井号的 ${UNTIL_CATEGORY_DOC}。
1480
+ **写一个真实物品 id 就只认它自己**:obsidian 不会拿哭泣的黑曜石顶,torch 不是灵魂火把,
1481
+ chest 不是末影箱,dirt 不是土径 —— 要"随便哪种木板"就写裸类别名 planks。
1482
+ count 一律 1-64;distance:flee 1-128,find 1-1024(不给 direction 时站着扫,最远 ${FIND_STATIC_MAX})。
1483
+ "needs" 声明这一步依赖哪些更早的步(序号 1 起);"needs":[] = 独立步。缺省见字段说明。
1484
+ 每一步都会核验:判据由执行器按 (skill, 参数) 推,达成与落空都写进回执
1485
+ (「该步按『背包内原木 ≥5』核验:达成(实测 7)」)。`;
1486
+
1487
+ function skillDocOf(skills: readonly SkillSpec[], lead: string): string {
1488
+ return [lead, ...skills.map((s) => s.doc), SKILL_DOC_TAIL].join('\n');
1489
+ }
1490
+
1491
+ /** 技能表的对外说明:直接作 mc_do 的 steps 参数说明。骨架来自注册表,首尾两段手写 */
1492
+ export const SKILL_DOC = skillDocOf(SKILLS, '一步一个对象,按顺序执行:');
1493
+
1494
+ /**
1495
+ * mc_scout 的 steps 说明:**指向 mc_do 那一份,不再重列技能条目**。
1496
+ *
1497
+ * 重列的那一版 53 行里 52 行逐字同于 `SKILL_DOC`(3,891 字符),而两处措辞一旦漂移
1498
+ * 就成了两套口径。结构化那份(`SCOUT_STEP_SCHEMA`)照旧是完整字段池 —— 解参数读的是它。
1499
+ */
1500
+ export const SCOUT_SKILL_DOC = [
1501
+ '一步一个对象,按顺序试算,不动世界。',
1502
+ `收 ${SCOUT_SKILLS.map((s) => s.name).join(' / ')} 这几个技能,写法与参数跟 mc_do 的 steps 完全一样`,
1503
+ '(见 mc_do 的说明),一律按试算跑,不用自己写 dryRun。',
1504
+ ].join('');
1505
+
1506
+ function defaultFieldSchema(f: FieldSpec): Record<string, unknown> {
1507
+ switch (f.kind) {
1508
+ case 'string': return { type: 'string' };
1509
+ case 'names': return { type: 'array', items: { type: 'string' }, maxItems: UNTIL_NAMES_MAX };
1510
+ case 'int': return { type: 'integer', minimum: f.lo, maximum: f.hi };
1511
+ case 'anchor': return { ...ANCHOR_SCHEMA };
1512
+ case 'flag': return { type: 'boolean' };
1513
+ case 'enum': return { type: 'string', enum: [...f.values] };
1514
+ case 'opaque': return { ...f.schema };
1515
+ }
1516
+ }
1517
+
1518
+ /** 字段的结构片段;description 不在这儿,合并后按用处列表另生成。 */
1519
+ function fieldSchema(f: FieldSpec): Record<string, unknown> {
1520
+ const base = f.schema ? { ...f.schema } : defaultFieldSchema(f);
1521
+ delete base.description;
1522
+ return base;
1523
+ }
1524
+
1525
+ /**
1526
+ * 同名字段跨技能的合并:区间取并集,枚举取并集,其余(type/items)本就一致,
1527
+ * 留先出场的那份。放宽到并集不丢约束——真校验在 parseSteps,schema 只管别把
1528
+ * 合法值挡在门外。
1529
+ */
1530
+ function widenField(
1531
+ into: Record<string, unknown>,
1532
+ add: Record<string, unknown>,
1533
+ ): Record<string, unknown> {
1534
+ const out = { ...into };
1535
+ for (const [k, v] of Object.entries(add)) {
1536
+ const prev = out[k];
1537
+ if (prev === undefined) out[k] = v;
1538
+ else if (k === 'minimum' || k === 'minItems') out[k] = Math.min(prev as number, v as number);
1539
+ else if (k === 'maximum' || k === 'maxItems') out[k] = Math.max(prev as number, v as number);
1540
+ else if (k === 'enum') out[k] = [...new Set([...(prev as string[]), ...(v as string[])])];
1541
+ }
1542
+ return out;
1543
+ }
1544
+
1545
+ /** 一个字段在某技能下的用处标注:「smelt」「goto(只写 [x,z] …)」「flee(1-128)」 */
1546
+ function fieldUse(skill: string, f: FieldSpec, merged: Record<string, unknown>): string {
1547
+ const bits: string[] = [];
1548
+ // 区间被别的技能撑宽了才写出来,否则 schema 自身就说清楚了
1549
+ if (f.kind === 'int' && (merged.minimum !== f.lo || merged.maximum !== f.hi)) {
1550
+ bits.push(`${f.lo}-${f.hi}`);
1551
+ }
1552
+ if (f.doc) bits.push(f.doc);
1553
+ return bits.length ? `${skill}(${bits.join(';')})` : skill;
1554
+ }
1555
+
1556
+ /**
1557
+ * steps.items schema:一份扁平字段池,description 写明每个字段用于哪些技能。
1558
+ * 每技能自己的必填与横向规则不进 schema:判别式 oneOf 不能用,有的 provider 解工具参数
1559
+ * 约束只认顶层 properties,分支里的字段会被整个吃掉,她填了也传不出来。硬约束改由
1560
+ * SKILL_DOC 陈述、parseSteps 裁决,报错回执带整张技能表。
1561
+ */
1562
+ function stepSchemaOf(skills: readonly SkillSpec[]): Record<string, unknown> {
1563
+ const pool = new Map<string, { schema: Record<string, unknown>; uses: Array<[string, FieldSpec]> }>();
1564
+ for (const s of skills) {
1565
+ for (const f of s.fields) {
1566
+ const prev = pool.get(f.key);
1567
+ pool.set(f.key, {
1568
+ schema: widenField(prev?.schema ?? {}, fieldSchema(f)),
1569
+ uses: [...(prev?.uses ?? []), [s.name, f]],
1570
+ });
1571
+ }
1572
+ }
1573
+ const fields = [...pool].map(([key, { schema, uses }]) => {
1574
+ const notes = uses.map(([skill, f]) => fieldUse(skill, f, schema));
1575
+ return [key, { ...schema, description: `用于 ${notes.join(' / ')}` }] as const;
1576
+ });
1577
+ return {
1578
+ type: 'object',
1579
+ properties: {
1580
+ skill: { type: 'string', enum: skills.map((s) => s.name) },
1581
+ ...Object.fromEntries(fields),
1582
+ needs: {
1583
+ type: 'array', items: { type: 'integer' },
1584
+ description: '任何技能都可带:依赖哪些更早的步(序号 1 起)。' +
1585
+ '不写=只拦因果:要用更早某步的产出、那一步又没做成时才跳过,其余照跑;[]=独立步',
1586
+ },
1587
+ // `expect` 不进 schema、也不进技能表:两轮实验(0821 给回显、0822 回显 791 次
1588
+ // 而声明 0/5440)之后放弃「让她自己声明验收标准」这条路,只留自动推导 + 回执回显。
1589
+ // 解析仍然收(见 `parseSteps`),写了照样覆写判据 —— 撤的是广告,不是能力。
1590
+ },
1591
+ required: ['skill'],
1592
+ };
1593
+ }
1594
+
1595
+ export const SKILL_STEP_SCHEMA: Record<string, unknown> = stepSchemaOf(SKILLS);
1596
+ export const SCOUT_STEP_SCHEMA: Record<string, unknown> = stepSchemaOf(SCOUT_SKILLS);
1597
+
1598
+ /** 表驱动的结构校验:按字段声明逐个过,规范对象只收声明过的键 */
1599
+ function parseByFields(
1600
+ spec: SkillSpec,
1601
+ c: Record<string, unknown>,
1602
+ at: string,
1603
+ marks?: MarkLookup,
1604
+ ): ParseResult {
1605
+ const out: Record<string, unknown> = { skill: spec.name };
1606
+ const notes: StepNote[] = [];
1607
+ for (const f of spec.fields) {
1608
+ const v = c[f.key];
1609
+ switch (f.kind) {
1610
+ case 'string': {
1611
+ const s = str(v);
1612
+ if (!s) {
1613
+ if (f.required) return { error: `${at} ${spec.name} 要 ${f.key}${f.hint ? `(${f.hint})` : ''}` };
1614
+ if (v !== undefined && v !== null) {
1615
+ notes.push({ field: f.key, given: v, kind: 'dropped', why: '不是一个名字' });
1616
+ }
1617
+ break;
1618
+ }
1619
+ out[f.key] = s;
1620
+ break;
1621
+ }
1622
+ case 'names': {
1623
+ if (v === undefined || v === null) break;
1624
+ if (!Array.isArray(v) || v.some((s) => typeof s !== 'string')) {
1625
+ return { error: `${at} ${spec.name} 的 ${f.key} 要是一串英文 id(数组)${f.hint ? `,${f.hint}` : ''}` };
1626
+ }
1627
+ if (v.length > UNTIL_NAMES_MAX) {
1628
+ return { error: `${at} ${spec.name} 的 ${f.key} 最多 ${UNTIL_NAMES_MAX} 项` };
1629
+ }
1630
+ const names = (v as string[]).map((s) => s.trim()).filter(Boolean);
1631
+ // 空数组不是「关掉」也不是错写:这一格本来就可以不给,收成没写
1632
+ if (names.length > 0) out[f.key] = names;
1633
+ else if (v.length > 0) notes.push({ field: f.key, given: v, kind: 'dropped', why: '全是空名字' });
1634
+ break;
1635
+ }
1636
+ case 'int': {
1637
+ const n = intIn(v, f.lo, f.hi, f.def);
1638
+ if (n === null) return { error: `${at} ${spec.name} 的 ${f.key} 要在 ${f.lo}-${f.hi} ${f.unit ?? ''}之间` };
1639
+ out[f.key] = n;
1640
+ break;
1641
+ }
1642
+ case 'anchor': {
1643
+ if (v === undefined) {
1644
+ if (f.required) return { error: `${at} ${f.error}` };
1645
+ break;
1646
+ }
1647
+ const a = anchorOf(v, marks);
1648
+ if (!a) return { error: `${at} ${f.error}${markMissNote(v, marks)}` };
1649
+ out[f.key] = a;
1650
+ break;
1651
+ }
1652
+ case 'flag': {
1653
+ if (v === true) out[f.key] = true;
1654
+ else if (v !== undefined && v !== null && v !== false) {
1655
+ notes.push({ field: f.key, given: v, kind: 'dropped', why: '只认 true' });
1656
+ }
1657
+ break;
1658
+ }
1659
+ case 'enum': {
1660
+ if (v === undefined && !f.required) break;
1661
+ const s = str(v);
1662
+ if (!s || !f.values.includes(s)) return { error: `${at} ${f.error}` };
1663
+ out[f.key] = s;
1664
+ break;
1665
+ }
1666
+ case 'opaque':
1667
+ // 复合结构只出现在带 parse 钩子的技能里,不会走到表驱动这条路
1668
+ break;
1669
+ }
1670
+ }
1671
+ return { step: out as unknown as SkillCall, ...(notes.length > 0 ? { notes } : {}) };
1672
+ }
1673
+
1674
+ /**
1675
+ * 这个技能不收、被整个丢掉的键。值是 null/undefined 的不算:schema 是一份扁平
1676
+ * 字段池,模型把用不上的字段填成 null 是照 schema 写的,不是她想说什么。
1677
+ */
1678
+ function strayKeys(spec: SkillSpec, c: Record<string, unknown>): StepNote[] {
1679
+ const known = new Set(['skill', 'needs', 'expect', ...spec.fields.map((f) => f.key)]);
1680
+ return Object.entries(c)
1681
+ .filter(([k, v]) => !known.has(k) && v !== undefined && v !== null)
1682
+ .map(([field, given]) => ({
1683
+ field, given, kind: 'dropped' as const, why: `${spec.name} 不收这个字段`,
1684
+ }));
1685
+ }
1686
+
1687
+ /**
1688
+ * mc_do 的边界校验采用整批原子语义;任一步非法时返回步骤索引和原因。
1689
+ * 有效步骤重建为规范对象,额外字段不进入技能实现。
1690
+ *
1691
+ * 规范化不是无声的:被丢掉的字段与被改读的写法进 `notes`(没有就不带这个键),
1692
+ * 由受理回执回念 —— 「解析后的调用与她写的不一致时明说」这条规矩的出口。
1693
+ */
1694
+ export function parseSteps(
1695
+ raw: unknown,
1696
+ marks?: MarkLookup,
1697
+ ): { steps: SkillCall[]; notes?: ParseNote[] } | { error: string } {
1698
+ if (!Array.isArray(raw) || raw.length === 0) return { error: 'steps 要是一个非空的技能数组' };
1699
+ const steps: SkillCall[] = [];
1700
+ const notes: ParseNote[] = [];
1701
+ for (const [i, item] of raw.entries()) {
1702
+ const at = `第 ${i + 1} 步`;
1703
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
1704
+ return { error: `${at}不是一个对象` };
1705
+ }
1706
+ const c = item as Record<string, unknown>;
1707
+ const name = str(c.skill);
1708
+ const spec = name ? SKILL_INDEX.get(name) : undefined;
1709
+ if (!spec) {
1710
+ return { error: `${at}的 skill「${String(c.skill)}」不是技能表里的动作` };
1711
+ }
1712
+ const parsed = spec.parse ? spec.parse(c, at, marks) : parseByFields(spec, c, at, marks);
1713
+ if ('error' in parsed) return parsed;
1714
+ const step = parsed.step;
1715
+ for (const n of [...(parsed.notes ?? []), ...strayKeys(spec, c)]) notes.push({ ...n, step: i + 1 });
1716
+ // 边界声明骑在规范步骤上;结构非法同样整批退回
1717
+ if (c.needs !== undefined) {
1718
+ const needs = needsOf(c.needs, i, at);
1719
+ if ('error' in needs) return needs;
1720
+ step.needs = needs.needs;
1721
+ }
1722
+ if (c.expect !== undefined) {
1723
+ const expect = expectOf(c.expect, at);
1724
+ if ('error' in expect) return expect;
1725
+ step.expect = expect.expect;
1726
+ }
1727
+ steps.push(step);
1728
+ }
1729
+ return notes.length > 0 ? { steps, notes } : { steps };
1730
+ }
1731
+
1732
+ function asScoutStep(step: SkillCall): SkillCall {
1733
+ switch (step.skill) {
1734
+ case 'goto':
1735
+ case 'build':
1736
+ case 'excavate':
1737
+ case 'tunnel':
1738
+ return { ...step, dryRun: true };
1739
+ default:
1740
+ return step;
1741
+ }
1742
+ }
1743
+
1744
+ /**
1745
+ * mc_scout 的入参:先走 parseSteps,再留下试算能做的步,并把 dryRun 补上。
1746
+ * 会动世界的技能整批退回——这是入口过滤,不是「没试算就不许动手」。
1747
+ */
1748
+ export function parseScoutSteps(
1749
+ raw: unknown,
1750
+ marks?: MarkLookup,
1751
+ ): { steps: SkillCall[]; notes?: ParseNote[] } | { error: string } {
1752
+ const parsed = parseSteps(raw, marks);
1753
+ if ('error' in parsed) return parsed;
1754
+ const scout = new Set<string>(SCOUT_SKILL_NAMES);
1755
+ const steps: SkillCall[] = [];
1756
+ for (const [i, step] of parsed.steps.entries()) {
1757
+ if (!scout.has(step.skill)) {
1758
+ return { error: `第 ${i + 1} 步「${step.skill}」会动世界,试算只收 probe 和带 dryRun 的技能` };
1759
+ }
1760
+ steps.push(asScoutStep(step));
1761
+ }
1762
+ return parsed.notes ? { steps, notes: parsed.notes } : { steps };
1763
+ }