cortico 0.1.0 → 0.1.2

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 (112) hide show
  1. package/README.md +7 -14
  2. package/package.json +1 -3
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +41 -6
  5. package/src/core/README.md +4 -6
  6. package/src/core/blobs.ts +23 -2
  7. package/src/core/config-schema.ts +1 -1
  8. package/src/core/config.ts +2 -16
  9. package/src/core/core.ts +15 -5
  10. package/src/core/loop.ts +55 -27
  11. package/src/core/session.ts +3 -2
  12. package/src/core/timers.ts +8 -6
  13. package/src/core/types.ts +19 -3
  14. package/src/core/util.ts +17 -1
  15. package/src/deploy.ts +4 -1
  16. package/src/extensions/README.md +6 -4
  17. package/src/extensions/dry-mount.ts +23 -2
  18. package/src/extensions/manifest.ts +21 -11
  19. package/src/extensions.ts +147 -23
  20. package/src/launcher.ts +23 -9
  21. package/src/protocol/open-responses/context-log.ts +37 -9
  22. package/src/providers/README.md +32 -8
  23. package/src/providers/base.ts +3 -1
  24. package/src/providers/configuration.ts +2 -1
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +8 -18
  27. package/src/providers/console/types.ts +5 -0
  28. package/src/providers/hub-api.ts +3 -0
  29. package/src/providers/llamacpp/config.ts +8 -6
  30. package/src/providers/llamacpp/console/runtime-panel.ts +5 -1
  31. package/src/providers/llamacpp/console/server.ts +3 -1
  32. package/src/providers/llamacpp/index.ts +3 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/strings.ts +6 -6
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +13 -0
  37. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  38. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  39. package/src/providers/openai-responses-compat/console/server.ts +127 -0
  40. package/src/providers/openai-responses-compat/index.ts +38 -13
  41. package/src/providers/openai-responses-compat/native.ts +9 -4
  42. package/src/providers/openai-responses-compat/strings.ts +62 -1
  43. package/src/providers/registry.ts +5 -0
  44. package/src/providers/transport/responses-input.ts +59 -10
  45. package/src/web/README.md +11 -6
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +5 -4
  48. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  49. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +6 -8
  50. package/src/web/client/console-pages/host.ts +24 -7
  51. package/src/web/client/core/api.ts +5 -1
  52. package/src/web/client/core/router.ts +15 -0
  53. package/src/web/client/features/core/strings.ts +2 -2
  54. package/src/web/client/features/extensions/index.ts +276 -41
  55. package/src/web/client/features/extensions/strings.ts +84 -10
  56. package/src/web/client/features/feature.ts +2 -2
  57. package/src/web/client/features/live/diagnostics.ts +32 -0
  58. package/src/web/client/features/live/index.ts +34 -11
  59. package/src/web/client/features/live/onboarding.ts +4 -1
  60. package/src/web/client/features/live/protocol.ts +1 -0
  61. package/src/web/client/features/live/strings.ts +20 -14
  62. package/src/web/client/features/live/timeline.ts +2 -1
  63. package/src/web/client/features/providers/detail.ts +262 -0
  64. package/src/web/client/features/providers/drafts.ts +23 -0
  65. package/src/web/client/features/providers/index.ts +173 -87
  66. package/src/web/client/features/providers/strings.ts +44 -17
  67. package/src/web/client/features/providers/types.ts +15 -0
  68. package/src/web/client/features/settings/general.ts +13 -0
  69. package/src/web/client/features/settings/index.ts +1 -0
  70. package/src/web/client/features/settings/strings.ts +6 -0
  71. package/src/web/client/features/worlds/index.ts +1 -0
  72. package/src/web/client/main.ts +4 -0
  73. package/src/web/client/shell/index.ts +28 -48
  74. package/src/web/client/shell/strings.ts +0 -22
  75. package/src/web/client/ui/icons.ts +14 -1
  76. package/src/web/client/ui/prompt-input.tsx +17 -5
  77. package/src/web/client/ui/strings.ts +0 -2
  78. package/src/web/console-pages.ts +1 -1
  79. package/src/web/diagnostics.ts +133 -0
  80. package/src/web/public/login.html +67 -0
  81. package/src/web/public/styles.css +143 -26
  82. package/src/web/server.ts +233 -19
  83. package/src/web/shared/client-panel.ts +4 -1
  84. package/src/web/shared/console-protocol.ts +4 -1
  85. package/src/worlds/bilibili/README.md +1 -1
  86. package/src/worlds/bilibili/overlay/server.ts +4 -2
  87. package/src/worlds/minecraft/ADAPT.md +66 -0
  88. package/src/worlds/minecraft/README.md +69 -11
  89. package/src/worlds/minecraft/cell-facts.ts +226 -0
  90. package/src/worlds/minecraft/chests.ts +5 -0
  91. package/src/worlds/minecraft/containers.ts +325 -0
  92. package/src/worlds/minecraft/entity-facts.ts +31 -5
  93. package/src/worlds/minecraft/executor.ts +283 -10348
  94. package/src/worlds/minecraft/inventory.ts +268 -0
  95. package/src/worlds/minecraft/melee.ts +419 -0
  96. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  97. package/src/worlds/minecraft/placed-ledger.ts +152 -0
  98. package/src/worlds/minecraft/placement.ts +1038 -0
  99. package/src/worlds/minecraft/receipt.ts +340 -0
  100. package/src/worlds/minecraft/skill-context.ts +358 -0
  101. package/src/worlds/minecraft/skills-build.ts +1169 -0
  102. package/src/worlds/minecraft/skills-container.ts +1343 -0
  103. package/src/worlds/minecraft/skills-craft.ts +333 -0
  104. package/src/worlds/minecraft/skills-dig.ts +624 -0
  105. package/src/worlds/minecraft/skills-gather.ts +1230 -0
  106. package/src/worlds/minecraft/skills-interact.ts +1559 -0
  107. package/src/worlds/minecraft/tools.ts +331 -0
  108. package/src/worlds/minecraft/travel.ts +763 -0
  109. package/src/worlds/minecraft/until.ts +75 -0
  110. package/src/worlds/qq/normalize.ts +14 -0
  111. package/src/worlds/qq/world.ts +53 -7
  112. package/src/worlds/terminal/world.ts +5 -3
@@ -0,0 +1,1559 @@
1
+ /**
2
+ * 右键这个世界:use 及其各种形态(点格子、对活物、写告示牌、上船、与猪灵交易),
3
+ * 以及 ride、lead、anvil、grindstone。
4
+ *
5
+ * 右键的结果一律回读世界或背包再报,不按客户端预测说话。
6
+ */
7
+ import { Vec3 } from 'vec3';
8
+ import type { Bot } from 'mineflayer';
9
+ import pathfinderPkg from 'mineflayer-pathfinder';
10
+ import { BLOCK_FACES, cellOnFace, type BlockFace, type Cell } from './geometry.ts';
11
+ import {
12
+ AIR_NAMES, LIQUIDS, blockAtCell, blockProp, cellText, dimensionOf, feetOf, resolveAt,
13
+ } from './cell-facts.ts';
14
+ import { zhDimension, zhEntity, zhName } from './names.ts';
15
+ import { SIGN_RE, signLinesText, zhThing } from './receipt.ts';
16
+ import { Aborted, SkillBlocked, SkillNoop, checkAbort, sleep, type SkillContext } from './skill-context.ts';
17
+ import {
18
+ FEED_ITEMS, TAME_ITEMS, dyeColorOf, isKnownTarget, readHorseTamed, readSaddled, readSheepColor,
19
+ readSitting, readTamedBy, tamedByMe, unknownUseTargetText,
20
+ } from './entity-facts.ts';
21
+ import { HOE_TILLED, SHOVEL_PATH } from './blueprint-registry.ts';
22
+ import {
23
+ askedLabel, invCount, invGains, invLosses, invSnapshot, itemAsked, noSuchItem, type InvPred,
24
+ } from './inventory.ts';
25
+ import { SEED_CROP, noteTilled, noteWork } from './placed-ledger.ts';
26
+ import { isBabyPiglin } from './piglin.ts';
27
+ import { DIRECTION_ZH, bearing, droppedStackOf } from './terrain.ts';
28
+ import { findEntity, gotoGoal, reachCell } from './travel.ts';
29
+ import { type SkillCall } from './skills.ts';
30
+ import { skillTrade } from './skills-gather.ts';
31
+ import { consumeHeldFood, equipNamed } from './skills-craft.ts';
32
+ import { matchItemName } from './chests.ts';
33
+ import {
34
+ ANVIL_BLOCKS, STATION_FIND_R, WINDOW_SETTLE_MS, findStationCell, openStationWindow,
35
+ putIntoStation, rememberWindow, stationItemFacts,
36
+ } from './containers.ts';
37
+ import { isSpawnAnchorBlock } from './policy.ts';
38
+ import { DRINKABLES } from './item-facts.ts';
39
+ import { nearestBoat } from './placement.ts';
40
+ import { itemMatchesPick } from './item-pick.ts';
41
+
42
+ const { goals } = pathfinderPkg;
43
+
44
+ /** 投掷类:朝 at 看一眼然后甩出去,不是往那一格放东西 */
45
+ export const THROWN = new Set([
46
+ 'splash_potion', 'lingering_potion', 'ender_pearl', 'snowball', 'egg',
47
+ 'experience_bottle', 'eye_of_ender', 'trident',
48
+ ]);
49
+
50
+ export function isThrown(name: string): boolean {
51
+ return THROWN.has(name) || name.startsWith('splash_') || name.startsWith('lingering_');
52
+ }
53
+
54
+ /** 船与竹筏(含带箱版):所有木种一个后缀就认全,新木种不必回来加名字 */
55
+ export function isBoat(name: string): boolean {
56
+ return name.endsWith('_boat') || name.endsWith('_raft');
57
+ }
58
+
59
+ /** 不写 at 放船时,按视线找落点面的射线上限(原版 BoatItem 的射线约 5 格) */
60
+ export const BOAT_CURSOR_RANGE = 5;
61
+
62
+ /** activateBlock 缺省点击顶面;效果表以此面计算外侧一格的位置。 */
63
+ export const USE_FACE: BlockFace = 'up';
64
+
65
+ /** 面名 → activateBlock 收的方向向量。 */
66
+ export function faceVector(face: BlockFace): Vec3 {
67
+ const [x, y, z] = BLOCK_FACES[face];
68
+ return new Vec3(x, y, z);
69
+ }
70
+
71
+ /**
72
+ * 往一块告示牌上写字。原版这件事分两步:**先右键把编辑框打开**(服务端由此记住
73
+ * "现在是谁在编辑这块牌子"),客户端再把四行字发回去;跳过第一步的 update_sign
74
+ * 服务端一律丢掉。上蜡的牌子编辑不了,原版连编辑框都不开——先说清楚,别发一个
75
+ * 注定被丢掉的包。
76
+ *
77
+ * 验收读的是写完之后服务端回灌的方块实体:`getSignText()` 给 [正面, 背面]。
78
+ * 读回来的那一份原样进回执 —— 这是她第一次能确认"观众看到的是这几个字"。
79
+ */
80
+ export async function writeSign(
81
+ bot: Bot, cell: Cell, target: NonNullable<ReturnType<Bot['blockAt']>>, text: string, back: boolean,
82
+ ): Promise<string> {
83
+ const where = `${cellText(cell)} 的${zhName(target.name)}`;
84
+ if (!SIGN_RE.test(target.name)) {
85
+ throw new SkillBlocked(`${where}不是告示牌,写不了字(text 只对告示牌有用)`);
86
+ }
87
+ // 上蜡记在方块实体的 is_waxed 里(不是 block state),读不到就当没上过——这一层不猜
88
+ const entityNbt = (target as unknown as { blockEntity?: Record<string, unknown> }).blockEntity;
89
+ if (entityNbt?.is_waxed === 1 || entityNbt?.is_waxed === true) {
90
+ throw new SkillBlocked(`${where}上过蜡,原版不让再编辑;先拿斧子右键把蜡刮掉`);
91
+ }
92
+ const side = back ? '背面' : '正面';
93
+ const readSide = (b: ReturnType<Bot['blockAt']>): string | null => {
94
+ const sign = b as unknown as { getSignText?: () => Array<string | undefined> } | null;
95
+ if (!sign || typeof sign.getSignText !== 'function') return null;
96
+ const both = sign.getSignText();
97
+ const one = back ? both[1] : both[0];
98
+ return typeof one === 'string' ? one.replace(/\s+$/, '') : null;
99
+ };
100
+ // 第一步:打开编辑框。空手右键才是"编辑",手上拿着染料/荧光墨囊/蜂巢时原版做的
101
+ // 是别的事(改色/发光/上蜡),那几件当场拦下来,不然写不进去还看不出为什么。
102
+ const held = bot.heldItem?.name ?? null;
103
+ if (held && (dyeColorOf(held) !== null || held === 'glow_ink_sac' || held === 'ink_sac' || held === 'honeycomb')) {
104
+ throw new SkillBlocked(
105
+ `手上拿着${zhName(held)}时右键牌子做的是改色/发光/上蜡,不是打开编辑框;先 {"skill":"equip"} 空手再来写`,
106
+ );
107
+ }
108
+ await bot.activateBlock(target);
109
+ await sleep(USE_SETTLE_MS);
110
+ bot.updateSign(target, text, back);
111
+ await sleep(USE_SETTLE_MS);
112
+ let now = readSide(bot.blockAt(new Vec3(cell.x, cell.y, cell.z)));
113
+ if (now === null || now !== text.replace(/\s+$/, '')) {
114
+ await sleep(USE_SETTLE_MS);
115
+ now = readSide(bot.blockAt(new Vec3(cell.x, cell.y, cell.z)));
116
+ }
117
+ const wrote = signLinesText(text);
118
+ if (now === null) {
119
+ return `往${where}${side}写了 ${wrote};读不回牌子上的字(这个版本的方块实体没给),写没写上核不了`;
120
+ }
121
+ if (now !== text.replace(/\s+$/, '')) {
122
+ throw new SkillBlocked(
123
+ `往${where}${side}写了 ${wrote},读回来是${now === '' ? '(空的)' : ` ${signLinesText(now)}`}`,
124
+ ['要看到的是:牌子上的字与写进去的一致'],
125
+ 'server',
126
+ );
127
+ }
128
+ return `${where}${side}现在写着 ${signLinesText(now)}`;
129
+ }
130
+
131
+ /** 右键之后给服务端回话的时间;第一次没读到就再等一拍(睡着、上鞍要一个来回) */
132
+ export const USE_SETTLE_MS = 400;
133
+
134
+ /** 右键之后世界该变成什么样;`want` 与实测读数成对进回执 */
135
+ export interface UseProbe {
136
+ /** 期望读到什么 */
137
+ want: string;
138
+ /** 右键之后读一次:办成了没有,以及实测读到的是什么 */
139
+ read(): { met: boolean; actual: string };
140
+ }
141
+
142
+ /**
143
+ * 锄地要求正上方 isAir,空碰撞形状的草、火把等也会阻挡。
144
+ * rooted_dirt 不要求头顶空气;farmland 已是目标状态,两者放行。
145
+ */
146
+ export function hoeCoverBlocked(bot: Bot, cell: Cell, target: string): string | null {
147
+ if (HOE_TILLED[target] === undefined || target === 'rooted_dirt' || target === 'farmland') return null;
148
+ const above = blockAtCell(bot, { x: cell.x, y: cell.y + 1, z: cell.z });
149
+ if (!above || AIR_NAMES.has(above.name)) return null;
150
+ return `${cellText(cell)} 头上盖着${zhName(above.name)},锄不动;先把它清掉`;
151
+ }
152
+
153
+ /** 空桶舀哪一格出哪一桶;流动的水舀不起来,读数自己会说(把握:确定) */
154
+ export const BUCKET_FILL: Record<string, string> = {
155
+ water: 'water_bucket',
156
+ lava: 'lava_bucket',
157
+ powder_snow: 'powder_snow_bucket',
158
+ };
159
+
160
+ /** 满桶倒出去就变回空桶(把握:确定) */
161
+ export const FULL_BUCKETS = new Set(['water_bucket', 'lava_bucket', 'powder_snow_bucket']);
162
+
163
+ /** 打火石点上去是自身 lit 翻牌、而不是外面出火的那几种(把握:比较确定) */
164
+ export const LIT_BY_FIRE = /^(soul_)?campfire$|candle(_cake)?$/;
165
+
166
+ /** 点着之后外面那一格是什么:一般出火,灵魂沙族出灵魂火,黑曜石框里当场成传送门(把握:确定) */
167
+ export const FIRE_BLOCKS = new Set(['fire', 'soul_fire', 'nether_portal']);
168
+
169
+ /**
170
+ * 右键就翻牌的开关族与它翻的那个属性。**判据是"翻了没有"而不是"翻成了哪一面"**——
171
+ * 翻牌不需要知道她想要开还是想要关,而铁门/铁活板门空手翻不动,正是要判出来的那一类。
172
+ * 按钮不在表里:它按下去自己会弹回来(石按钮 1 秒),读数是赛跑,判不了(把握:确定)。
173
+ */
174
+ export const TOGGLES: ReadonlyArray<{ re: RegExp; prop: string }> = [
175
+ { re: /(^|_)door$/, prop: 'open' },
176
+ { re: /(^|_)trapdoor$/, prop: 'open' },
177
+ { re: /_fence_gate$/, prop: 'open' },
178
+ { re: /^lever$/, prop: 'powered' },
179
+ ];
180
+
181
+ /** 挤得出奶的(把握:牛/哞菇确定,山羊比较确定) */
182
+ export const MILKABLE = new Set(['cow', 'mooshroom', 'goat']);
183
+
184
+ /** 上得了鞍的。**没驯服的马驴上不了**,鞍留在包里,读数自己会说(把握:比较确定) */
185
+ export const SADDLEABLE = new Set(['horse', 'donkey', 'mule', 'pig', 'strider', 'camel']);
186
+
187
+ /**
188
+ * 读一格变成了什么;本来就是那样也算办成(与 build「本来就是火把」同一条)。
189
+ * `where` 是读数在回执里怎么称呼这一格:被点的那一格叫「那一格」(回执头一句已经
190
+ * 报过坐标),读外面一格的那几条要报出坐标,否则看不出读的是 y+1。
191
+ */
192
+ export function probeCell(
193
+ bot: Bot,
194
+ cell: Cell,
195
+ accept: (name: string) => boolean,
196
+ want: string,
197
+ where = '那一格',
198
+ ): UseProbe {
199
+ const was = blockAtCell(bot, cell)?.name ?? null;
200
+ return {
201
+ want,
202
+ read: () => {
203
+ const b = blockAtCell(bot, cell);
204
+ if (!b) return { met: false, actual: `${cellText(cell)} 那里区块没加载` };
205
+ const met = accept(b.name);
206
+ const verb = b.name !== was ? '现在是' : met ? '本来就是' : '还是';
207
+ return { met, actual: `${where}${verb}${zhName(b.name)}` };
208
+ },
209
+ };
210
+ }
211
+
212
+ /** 读包:某一类东西的净增(gain)或净减 */
213
+ export function probeInv(
214
+ bot: Bot,
215
+ pred: (name: string) => boolean,
216
+ gain: boolean,
217
+ label: string,
218
+ want: string,
219
+ ): UseProbe {
220
+ const was = invCount(bot, pred);
221
+ return {
222
+ want,
223
+ read: () => {
224
+ const now = invCount(bot, pred);
225
+ return { met: gain ? now > was : now < was, actual: `包里${label} ${was} → ${now} 个` };
226
+ },
227
+ };
228
+ }
229
+
230
+ /** 读被点那一格的方块状态属性(原版属性名直给);属性读不到就整条判不了,由调用方退回现状 */
231
+ export function probeProp(
232
+ bot: Bot,
233
+ cell: Cell,
234
+ key: string,
235
+ met: (was: string, now: string | null) => boolean,
236
+ want: string,
237
+ ): UseProbe | null {
238
+ const was = blockProp(blockAtCell(bot, cell), key);
239
+ if (was === null) return null;
240
+ return {
241
+ want,
242
+ read: () => {
243
+ const now = blockProp(blockAtCell(bot, cell), key);
244
+ return { met: met(was, now), actual: `${key} ${was} → ${now ?? '(读不到)'}` };
245
+ },
246
+ };
247
+ }
248
+
249
+ /**
250
+ * 按物品与目标确定右键效果的观测位置:床读 sleeping,种子读耕地上方。
251
+ * 无法确定时返回 null,仅报告事实并记 debug。
252
+ */
253
+ export function useProbeAt(
254
+ bot: Bot, item: string | null, cell: Cell, target: string, ctx?: SkillContext, face: BlockFace = USE_FACE,
255
+ ): UseProbe | null {
256
+ const out = cellOnFace(cell, face);
257
+
258
+ // 床、门、拉杆这几条与手上拿什么无关:原版里非潜行右键交互方块一律走交互
259
+ if (/(^|_)bed$/.test(target)) {
260
+ return {
261
+ want: '躺下睡着',
262
+ read: () => {
263
+ if (bot.isSleeping === true) return { met: true, actual: '躺下了' };
264
+ // startSleepInBed 先设置重生点,再判断能否睡眠;白天也可能改点但未入睡。
265
+ const spawn = ctx?.spawnNote?.() ?? null;
266
+ return {
267
+ met: false,
268
+ actual: `没躺下(现在 dayTime ${Math.round(bot.time?.timeOfDay ?? 0)})${spawn ? `,${spawn}` : ''}`,
269
+ };
270
+ },
271
+ };
272
+ }
273
+ for (const t of TOGGLES) {
274
+ if (t.re.test(target)) {
275
+ return probeProp(bot, cell, t.prop, (was, now) => now !== null && now !== was, `${t.prop} 翻个面`);
276
+ }
277
+ }
278
+
279
+ if (item === null) return null;
280
+ if (item.endsWith('_hoe')) {
281
+ const tilled = HOE_TILLED[target];
282
+ return tilled ? probeCell(bot, cell, (n) => n === tilled, `${cellText(cell)} 变成${zhName(tilled)}`) : null;
283
+ }
284
+ if (item.endsWith('_shovel')) {
285
+ const path = SHOVEL_PATH[target];
286
+ return path ? probeCell(bot, cell, (n) => n === path, `${cellText(cell)} 变成${zhName(path)}`) : null;
287
+ }
288
+ const crop = SEED_CROP[item];
289
+ if (crop !== undefined) {
290
+ return target === 'farmland'
291
+ ? probeCell(bot, out, (n) => n === crop, `${cellText(out)} 长出${zhName(crop)}`, `${cellText(out)} `)
292
+ : null;
293
+ }
294
+ if (item === 'nether_wart') {
295
+ return target === 'soul_sand'
296
+ ? probeCell(bot, out, (n) => n === 'nether_wart', `${cellText(out)} 长出下界疣`, `${cellText(out)} `)
297
+ : null;
298
+ }
299
+ if (item === 'bucket') {
300
+ const filled = BUCKET_FILL[target];
301
+ return filled
302
+ ? probeInv(bot, (n) => n === filled, true, zhName(filled), `包里多一个${zhName(filled)}`)
303
+ : null;
304
+ }
305
+ // 玻璃瓶只从水源装得到水;装满出来的物品 id 是 potion(水瓶与药水同名,靠内容区分)
306
+ if (item === 'glass_bottle') {
307
+ return target === 'water'
308
+ ? probeInv(bot, (n) => n === 'potion', true, '水瓶', '包里多一个水瓶(物品 id 是 potion)')
309
+ : null;
310
+ }
311
+ if (FULL_BUCKETS.has(item)) {
312
+ return probeInv(bot, (n) => n === 'bucket', true, '空桶', '手上那桶倒出去,包里多一个空桶');
313
+ }
314
+ if (item === 'flint_and_steel' || item === 'fire_charge') {
315
+ // 打火石点的是一个方块的面,空气与液体没有面可点
316
+ if (AIR_NAMES.has(target) || LIQUIDS.has(target)) return null;
317
+ if (target === 'tnt') return probeCell(bot, cell, (n) => AIR_NAMES.has(n), 'TNT 点着飞出去,那一格空出来');
318
+ if (LIT_BY_FIRE.test(target)) return probeProp(bot, cell, 'lit', (_was, now) => now === 'true', 'lit 变 true');
319
+ return probeCell(bot, out, (n) => FIRE_BLOCKS.has(n), `${cellText(out)} 烧起来`, `${cellText(out)} `);
320
+ }
321
+ if (item === 'bone_meal') {
322
+ // 满龄的作物再撒骨粉原版什么都不发生、骨粉也不消耗,所以 age 没往上走就是没催动
323
+ return probeProp(bot, cell, 'age', (was, now) => now !== null && Number(now) > Number(was), 'age 往上走一档');
324
+ }
325
+ // 唱片放进唱片机:方块自己的 has_record 翻牌,比"包里少了一张唱片"更靠近观众听见的那件事
326
+ if (item !== null && item.startsWith('music_disc_') && target === 'jukebox') {
327
+ return probeProp(bot, cell, 'has_record', (_was, now) => now === 'true', 'has_record 变 true(开始放了)');
328
+ }
329
+ // 篝火烤东西:原版一次只收一件,烤不下(四个位置满了/这东西不能烤)时**不消耗**
330
+ if (item !== null && CAMPFIRES.has(target) && (bot.registry?.foodsByName as Record<string, unknown> | undefined)?.[item]) {
331
+ return probeInv(bot, (n) => n === item, false, zhName(item), `${zhName(item)}被放上篝火(包里少一个)`);
332
+ }
333
+ // 展示框与画不是方块,是贴在某一面上的实体:世界侧读不到,读"包里少了一个"
334
+ if (item !== null && WALL_ENTITY_ITEMS.has(item)) {
335
+ return probeInv(bot, (n) => n === item, false, zhName(item), `${zhName(item)}挂上去(包里少一个)`);
336
+ }
337
+ return null;
338
+ }
339
+
340
+ /** 烤东西的那两种火堆(把握:确定) */
341
+ export const CAMPFIRES = new Set(['campfire', 'soul_campfire']);
342
+
343
+ /** 画只允许侧面,展示框允许六面;此类物品需明确放置面。 */
344
+ export const WALL_ENTITY_ITEMS = new Set(['item_frame', 'glow_item_frame', 'painting']);
345
+
346
+ /** (手上这样东西 × 右键的那只活物)→ 右键之后该读哪儿;判不了返回 null */
347
+ export function useProbeOn(bot: Bot, item: string | null, target: string, entity?: unknown): UseProbe | null {
348
+ if (item === 'shears' && target === 'sheep') {
349
+ return probeInv(bot, (n) => n.endsWith('_wool'), true, '羊毛', '包里多出羊毛');
350
+ }
351
+ if (item === 'bucket' && MILKABLE.has(target)) {
352
+ return probeInv(bot, (n) => n === 'milk_bucket', true, '奶桶', '包里多一桶奶');
353
+ }
354
+ if (item === 'saddle' && SADDLEABLE.has(target)) {
355
+ return probeInv(bot, (n) => n === 'saddle', false, '鞍', '鞍从包里装到它身上');
356
+ }
357
+ // 染羊:原版只在"颜色真的变了"时才消耗染料,所以读回它现在的颜色是精确判据
358
+ const dye = dyeColorOf(item);
359
+ if (dye !== null && target === 'sheep') {
360
+ return {
361
+ want: `这只羊身上变成${zhName(`${dye}_wool`)}`,
362
+ read: () => {
363
+ const now = readSheepColor(bot as never, (entity ?? {}) as never);
364
+ if (now === null) return { met: false, actual: '读不到它身上的羊毛颜色' };
365
+ return { met: now === dye, actual: `它现在身上是${zhName(`${now}_wool`)}` };
366
+ },
367
+ };
368
+ }
369
+ return null;
370
+ }
371
+
372
+ /**
373
+ * 右键活物之后附在回执尾巴上的**事实**(不判成没成)。驯服与喂食都是"这一下被
374
+ * 接受了"与"目标状态到了没有"两回事:骨头每次都会被吃掉而驯服是随机的(原版 1/3),
375
+ * 喂食则相反——**喂不进去就不消耗**。把这两条规则连同当下读数一起说清,
376
+ * 她自己就能判断该不该再来一次;判断本身不替她做。
377
+ */
378
+ export function useNoteOn(bot: Bot, item: string | null, target: string, entity: unknown): string {
379
+ if (!item) {
380
+ // 驯服类空手交互按服务端元数据回报坐姿等状态。
381
+ if (TAME_ITEMS[target] === undefined) return '';
382
+ const sit = readSitting(bot as never, entity as never);
383
+ return sit === null ? '' : `;它现在${sit ? '坐着' : '站着'}`;
384
+ }
385
+ if ((TAME_ITEMS[target] ?? []).includes(item)) {
386
+ const owner = readTamedBy(bot as never, entity as never);
387
+ const mine = tamedByMe(bot as never, entity as never);
388
+ const state = owner === null ? '它现在还没有主人' : mine ? '它认你当主人了' : '它已经有别的主人了';
389
+ return `;${state}(${zhName(item)}每次都会被吃掉,驯服成不成是随机的,没成就再来一次)`;
390
+ }
391
+ if ((FEED_ITEMS[target] ?? []).includes(item)) {
392
+ return `;原版喂不进去就不会消耗${zhName(item)}——包里少了 1 个就是这一口被接受了,一个没少说明它现在吃不进去`
393
+ + '(未成年、刚繁殖过还在冷却、或者不吃这个)';
394
+ }
395
+ return '';
396
+ }
397
+
398
+ /** 右键骑乘后报告当前坐骑并说明 ride 用法,不自动下车。 */
399
+ export function leaveVehicle(bot: Bot, target: string): string {
400
+ const vehicle = (bot as unknown as { vehicle?: { name?: string } | null }).vehicle;
401
+ if (!vehicle) return '';
402
+ return `;人已经骑在${zhEntity(target)}身上了:驾着走用 {"skill":"ride","to":[x,y,z]},下来用 {"skill":"ride","off":true}`;
403
+ }
404
+
405
+ /**
406
+ * 落在效果表外的那一对记一条。**这张表要长出来只能靠它**:下一场直接按
407
+ * `use-off-table` 就数得出还缺哪些对,不必再从全场回执里反推。
408
+ */
409
+ export function noteOffTable(ctx: SkillContext, item: string | null, target: string): void {
410
+ ctx.diag?.write({
411
+ lane: 'skill', event: 'use-off-table', taskId: ctx.taskId,
412
+ msg: `${item ? zhName(item) : '空手'}右键${zhThing(target)}:效果表里没这一对,只报事实`,
413
+ data: { item, target },
414
+ });
415
+ }
416
+
417
+ /**
418
+ * 睡着之后等到醒。上限按一个游戏夜给足余量:原版躺下被受理后 101 tick(约 5 秒)
419
+ * 就天亮,60 秒是它的十几倍;真卡在这上限里(别人不睡、被打断)就自己起来,
420
+ * 不能把整条队列拖在床上。
421
+ */
422
+ export const BED_WAKE_MS = 60_000;
423
+ export const BED_WAKE_POLL_MS = 250;
424
+
425
+ /**
426
+ * 躺下之后不返回,等到醒 —— 这一步还没结束,后面那一步的寻路就动不了身。
427
+ * 被抢占(战斗/mc_stop/World 停机)当场收工:那些抢占本来就该把人从床上叫起来。
428
+ * 返回回执里的那半句(等了多久、是自己醒的还是到点起的)。
429
+ */
430
+ export async function waitForWake(bot: Bot, ctx: SkillContext): Promise<string> {
431
+ if (bot.isSleeping !== true) return '';
432
+ const from = Date.now();
433
+ // 周期进度事件在这段时间里要换一句话说:人不动是因为在睡,不是卡住了
434
+ ctx.sleeping = true;
435
+ try {
436
+ while (bot.isSleeping === true && Date.now() - from < BED_WAKE_MS && !ctx.aborted()) {
437
+ await sleep(BED_WAKE_POLL_MS);
438
+ }
439
+ } finally {
440
+ ctx.sleeping = false;
441
+ }
442
+ const secs = Math.round((Date.now() - from) / 1000);
443
+ if (bot.isSleeping !== true) return `,一直躺到醒(${secs}s,这段时间没动身)`;
444
+ try {
445
+ await bot.wake();
446
+ } catch {
447
+ /* 已经不在床上了 */
448
+ }
449
+ return `,躺了 ${secs}s 还没到天亮,自己起来了`;
450
+ }
451
+
452
+ /** 读一次;没读到就再等一拍读第二次(服务端回话可能落在第一次读之后) */
453
+ export async function settleProbe(probe: UseProbe): Promise<{ met: boolean; actual: string }> {
454
+ const first = probe.read();
455
+ if (first.met) return first;
456
+ await sleep(USE_SETTLE_MS);
457
+ return probe.read();
458
+ }
459
+
460
+ /** 一次右键在包里留下的痕迹:进包的与用掉的都报。一样没动返回空串,不占一句话 */
461
+ export function useInvNote(before: Map<string, number>, bot: Bot): string {
462
+ const parts: string[] = [];
463
+ const gains = invGains(before, bot);
464
+ const losses = invLosses(before, bot);
465
+ if (gains.length > 0) parts.push(`进包:${gains.join('、')}`);
466
+ if (losses.length > 0) parts.push(`用掉:${losses.join('、')}`);
467
+ return parts.join(';');
468
+ }
469
+
470
+ export const PIGLIN_ACCEPT_MS = 1_500;
471
+ export const PIGLIN_GIFT_MS = 10_000;
472
+
473
+ export function adultPiglins(bot: Bot, radius: number): Array<NonNullable<Bot['entities'][string]>> {
474
+ const me = bot.entity.position;
475
+ return Object.values(bot.entities)
476
+ .filter((e): e is NonNullable<Bot['entities'][string]> => Boolean(
477
+ e?.isValid && e.name === 'piglin' && e.position
478
+ && e.position.distanceTo(me) <= radius && !isBabyPiglin(bot, e),
479
+ ))
480
+ .sort((a, b) => a.position.distanceTo(me) - b.position.distanceTo(me));
481
+ }
482
+
483
+ export function piglinDrops(
484
+ bot: Bot,
485
+ beforeIds: ReadonlySet<number>,
486
+ near: { x: number; y: number; z: number },
487
+ ): Array<{ id: number; name: string; count: number; x: number; y: number; z: number }> {
488
+ const out: Array<{ id: number; name: string; count: number; x: number; y: number; z: number }> = [];
489
+ const center = new Vec3(near.x, near.y, near.z);
490
+ for (const e of Object.values(bot.entities)) {
491
+ if (!e?.isValid || typeof e.id !== 'number' || beforeIds.has(e.id) || !e.position) continue;
492
+ const stack = droppedStackOf(e);
493
+ if (!stack || e.position.distanceTo(center) > 5) continue;
494
+ out.push({ id: e.id, name: stack.name, count: stack.count, x: e.position.x, y: e.position.y, z: e.position.z });
495
+ }
496
+ return out;
497
+ }
498
+
499
+ /** 给金后的数秒延迟属于这次交互的一部分;看到实际回礼或明确超时才结束。 */
500
+ export async function barterPiglinOnce(bot: Bot, ctx: SkillContext): Promise<string> {
501
+ const adults = adultPiglins(bot, 32);
502
+ if (adults.length === 0) {
503
+ const babies = Object.values(bot.entities).filter((e) =>
504
+ e?.isValid && e.name === 'piglin' && e.position.distanceTo(bot.entity.position) <= 32
505
+ && isBabyPiglin(bot, e));
506
+ if (babies.length > 0) throw new SkillBlocked('附近只有幼年猪灵;它不会以物易物,金锭没有交出去');
507
+ throw new SkillNoop('附近 32 格内没有成年猪灵');
508
+ }
509
+ const piglin = adults[0];
510
+ await gotoGoal(bot, new goals.GoalFollow(piglin, 2), ctx).catch(() => undefined);
511
+ checkAbort(ctx);
512
+ if (!piglin.isValid) throw new SkillBlocked('走到跟前时成年猪灵已经走了');
513
+ await bot.lookAt(piglin.position.offset(0, (piglin.height ?? 1) * 0.5, 0));
514
+
515
+ const beforeInv = invSnapshot(bot);
516
+ const beforeGold = invCount(bot, (name) => name === 'gold_ingot');
517
+ const beforeIds = new Set(Object.values(bot.entities)
518
+ .map((e) => e?.id)
519
+ .filter((id): id is number => typeof id === 'number'));
520
+ const clickAt = { x: piglin.position.x, y: piglin.position.y, z: piglin.position.z };
521
+ await bot.useOn(piglin);
522
+
523
+ const acceptedUntil = Date.now() + PIGLIN_ACCEPT_MS;
524
+ while (Date.now() < acceptedUntil && invCount(bot, (name) => name === 'gold_ingot') >= beforeGold) {
525
+ checkAbort(ctx);
526
+ await sleep(100);
527
+ }
528
+ const afterGold = invCount(bot, (name) => name === 'gold_ingot');
529
+ if (afterGold >= beforeGold) {
530
+ throw new SkillBlocked('成年猪灵没有收下金锭:包里的金锭数量没变,这次没有成交', [], 'server');
531
+ }
532
+
533
+ const giftUntil = Date.now() + PIGLIN_GIFT_MS;
534
+ while (Date.now() < giftUntil) {
535
+ checkAbort(ctx);
536
+ const gains = invGains(beforeInv, bot);
537
+ if (gains.length > 0) {
538
+ return `成年猪灵收了金锭×${beforeGold - afterGold},回礼已经进包:${gains.join('、')}`;
539
+ }
540
+ const drops = piglinDrops(bot, beforeIds, piglin.isValid ? piglin.position : clickAt);
541
+ if (drops.length > 0) {
542
+ await sleep(300);
543
+ const settled = piglinDrops(bot, beforeIds, piglin.isValid ? piglin.position : clickAt);
544
+ const seen = settled.length > 0 ? settled : drops;
545
+ return `成年猪灵收了金锭×${beforeGold - afterGold},回礼落在地上:`
546
+ + seen.map((d) => `${zhName(d.name)}×${d.count} (${Math.round(d.x)}, ${Math.round(d.y)}, ${Math.round(d.z)})`).join('、')
547
+ + '。这一步没有替你捡';
548
+ }
549
+ await sleep(100);
550
+ }
551
+ throw new SkillBlocked(
552
+ `成年猪灵已经收走金锭×${beforeGold - afterGold},但 ${Math.round(PIGLIN_GIFT_MS / 1000)} 秒内没观察到回礼;金锭已消耗,结果未知`,
553
+ [],
554
+ 'server',
555
+ );
556
+ }
557
+
558
+ /** 未被效果表识别的空气或液体交互按受阻回报;桶类使用由专门分支处理。 */
559
+ export function nothingThere(bot: Bot, cell: Cell, name: string, label: string): SkillBlocked {
560
+ const below = { x: cell.x, y: cell.y - 1, z: cell.z };
561
+ const b = blockAtCell(bot, below);
562
+ return new SkillBlocked(
563
+ `${cellText(cell)} 那一格是${AIR_NAMES.has(name) ? '空气' : zhName(name)},${label}右键它不产生任何动作`,
564
+ [`下面一格 ${cellText(below)} 是${b ? zhName(b.name) : '(区块没加载)'}`],
565
+ );
566
+ }
567
+
568
+ /**
569
+ * 右键 `times` 次(缺省 1)。每次都重新拿一次手上那样东西,所以东西中途用完
570
+ * 就停在那一次:做成几次是事实,报出来;一次都没做成才是受阻(与 build
571
+ * 「一块都没放上就是受阻」同一条)。
572
+ */
573
+ export async function skillUse(bot: Bot, call: Extract<SkillCall, { skill: 'use' }>, ctx: SkillContext): Promise<string> {
574
+ // 商人的"右键"开出来的是报价窗口,交易两段式走自己那条路(times 在那边是成交几次)
575
+ if (call.target === 'villager' || call.target === 'wandering_trader') {
576
+ return skillTrade(bot, { ...call, target: call.target }, ctx);
577
+ }
578
+ const times = call.times ?? 1;
579
+ let last = '';
580
+ let done = 0;
581
+ const receipts: string[] = [];
582
+ // times > 1 时同时回报整段库存净差与最后一次现场读数。
583
+ const beforeSpan = invSnapshot(bot);
584
+ const spanNote = () => {
585
+ const net = useInvNote(beforeSpan, bot);
586
+ return `这 ${done} 次合计${net ? `(${net})` : '包里一样没动'}`;
587
+ };
588
+ for (let i = 0; i < times; i += 1) {
589
+ try {
590
+ last = await useOnce(bot, call, ctx);
591
+ receipts.push(last);
592
+ done += 1;
593
+ } catch (err) {
594
+ if (err instanceof Aborted || done === 0) throw err;
595
+ if (call.target === 'piglin' && call.item === 'gold_ingot') {
596
+ ctx.partial?.(`第 ${done + 1} 次交易没有得到可确认的回礼:${(err as Error).message}`);
597
+ }
598
+ const finished = call.target === 'piglin' && call.item === 'gold_ingot'
599
+ ? `已完成:${receipts.join(';')}`
600
+ : `${spanNote()}。最后一次:${last}`;
601
+ return `右键了 ${done}/${times} 次,第 ${done + 1} 次停下:${(err as Error).message}。${finished}`;
602
+ }
603
+ }
604
+ if (times === 1) return last;
605
+ return call.target === 'piglin' && call.item === 'gold_ingot'
606
+ ? `交易了 ${done}/${times} 次:${receipts.join(';')}`
607
+ : `右键了 ${done}/${times} 次,${spanNote()}。最后一次:${last}`;
608
+ }
609
+
610
+ /** 一次右键:三种宾语(某一格 / 某只活物 / 手上这样东西本身) */
611
+ export async function useOnce(bot: Bot, call: Extract<SkillCall, { skill: 'use' }>, ctx: SkillContext): Promise<string> {
612
+ // 未指定 item 时,按当前实际手持物回报。
613
+ let held: string | null;
614
+ if (call.item) {
615
+ try {
616
+ held = await equipNamed(bot, call.item);
617
+ } catch (err) {
618
+ // 点名物品缺货而 at 格本身就是该物品时,回执提示直接右键该格的写法。
619
+ if (err instanceof SkillBlocked && call.at) {
620
+ const cell = resolveAt(bot, call.at);
621
+ const b = blockAtCell(bot, cell);
622
+ if (b && matchItemName(call.item, b.name)) {
623
+ throw new SkillBlocked(
624
+ `${err.message};不过 ${cellText(cell)} 那一格本身就是${zhName(b.name)}——` +
625
+ `要右键它不用带 item,写 {"skill":"use","at":[${cell.x},${cell.y},${cell.z}]} 空手点它就行`,
626
+ );
627
+ }
628
+ }
629
+ throw err;
630
+ }
631
+ } else {
632
+ held = bot.heldItem?.name ?? null;
633
+ }
634
+ const label = held ? zhName(held) : '空手';
635
+
636
+ if (call.target === 'piglin_brute' && held === 'gold_ingot') {
637
+ throw new SkillBlocked('猪灵蛮兵不接受以物易物,金锭没有交出去');
638
+ }
639
+ if (call.target === 'piglin' && held === 'gold_ingot') return barterPiglinOnce(bot, ctx);
640
+
641
+ if (call.target) {
642
+ if (!isKnownTarget(bot, call.target)) throw new SkillBlocked(unknownUseTargetText(bot, call.target));
643
+ const entity = findEntity(bot, call.target, 32);
644
+ if (!entity) throw new SkillNoop(`附近 32 格内没有${zhEntity(call.target)}`);
645
+ await gotoGoal(bot, new goals.GoalFollow(entity, 2), ctx).catch(() => undefined);
646
+ checkAbort(ctx);
647
+ if (!entity.isValid) throw new SkillBlocked(`${zhEntity(call.target)}走了`);
648
+ await bot.lookAt(entity.position.offset(0, (entity.height ?? 1) * 0.5, 0));
649
+ const beforeInv = invSnapshot(bot);
650
+ const probe = useProbeOn(bot, held, call.target, entity);
651
+ await bot.useOn(entity);
652
+ await sleep(USE_SETTLE_MS);
653
+ const head = `${label}右键了${zhEntity(call.target)}`;
654
+ const note = useInvNote(beforeInv, bot);
655
+ const facts = useNoteOn(bot, held, call.target, entity) + leaveVehicle(bot, call.target);
656
+ if (!probe) {
657
+ noteOffTable(ctx, held, call.target);
658
+ return `${head}。${note || '包里一样没动'}${facts}`;
659
+ }
660
+ const v = await settleProbe(probe);
661
+ if (!v.met) throw new SkillBlocked(`${head},${v.actual}${facts}`, [`要看到的是:${probe.want}`], 'server');
662
+ return `${head},${v.actual}${note ? `。${note}` : ''}${facts}`;
663
+ }
664
+
665
+ // 放船未指定 at 时使用当前视线命中的方块;无命中则受阻。
666
+ if (held && isBoat(held) && !call.at) {
667
+ const cur = bot.blockAtCursor?.(BOAT_CURSOR_RANGE);
668
+ if (!cur) {
669
+ throw new SkillBlocked(
670
+ `手上是${label}而没给 at,视线 ${BOAT_CURSOR_RANGE} 格内又没有方块;放船要么看着水面/地面,要么给 at 指一格落点`,
671
+ );
672
+ }
673
+ return await useBoat(bot, { x: cur.position.x, y: cur.position.y, z: cur.position.z }, held, label);
674
+ }
675
+
676
+ if (call.at) {
677
+ const cell = resolveAt(bot, call.at);
678
+ // 投掷物的 at 是落点方向,不是要改的那一格
679
+ if (held && isThrown(held)) {
680
+ const before = invCount(bot, (n) => n === held);
681
+ await aimThenUse(bot, new Vec3(cell.x + 0.5, cell.y + 0.5, cell.z + 0.5));
682
+ await sleep(300);
683
+ return `朝 ${cellText(cell)} 扔了${label};包里还有 ${invCount(bot, (n) => n === held)} 个(扔前 ${before})`;
684
+ }
685
+ await reachCell(bot, cell, ctx);
686
+ checkAbort(ctx);
687
+ const target = blockAtCell(bot, cell);
688
+ if (!target) throw new SkillBlocked(`${cellText(cell)} 所在区块没加载`);
689
+ // 船由 BoatItem 的 use 沿玩家视线生成,不能通过 use_item_on 放置。
690
+ if (held && isBoat(held)) return await useBoat(bot, cell, held, label);
691
+ if (held?.endsWith('_hoe')) {
692
+ const covered = hoeCoverBlocked(bot, cell, target.name);
693
+ if (covered) throw new SkillBlocked(covered);
694
+ }
695
+ const bucketFluid = held === 'bucket' && (target.name === 'water' || target.name === 'lava');
696
+ const fluidLevel = bucketFluid ? blockProp(target, 'level') : null;
697
+ const fluidSource = bucketFluid ? fluidLevel === '0' : false;
698
+ const fluidFact = bucketFluid
699
+ ? `level=${fluidLevel ?? '(读不到)'}, source=${fluidSource ? 'true' : 'false'}`
700
+ : '';
701
+ // 空桶只舀原版源方块。先读 block state 再发 use 包,流动液体不会消耗一次无效右键。
702
+ if (bucketFluid && !fluidSource) {
703
+ throw new SkillBlocked(
704
+ `${cellText(cell)} 的${zhName(target.name)}是流动液体或来源状态不可读(${fluidFact});空桶只舀 level=0 的源方块`,
705
+ );
706
+ }
707
+ // 写牌子先走独立牌面验收,不进入通用效果表的表外记录。
708
+ if (call.text !== undefined) return await writeSign(bot, cell, target, call.text, call.back === true);
709
+ // 空手右键告示牌打开 open_sign_editor;它不是窗口包,rememberWindow 无法观察。
710
+ if (!held && SIGN_RE.test(target.name)) {
711
+ await bot.activateBlock(target);
712
+ await sleep(USE_SETTLE_MS);
713
+ return `空手右键了 ${cellText(cell)} 的${zhName(target.name)}:这一下把编辑框打开了,没写字;要写字就在同一条 use 里给 text`;
714
+ }
715
+ // (item, 目标方块) 表决定去哪儿读;表外那一对退回「报事实不下结论」
716
+ const probe = useProbeAt(bot, held, cell, target.name, ctx, call.face);
717
+ if (!probe) {
718
+ // 空桶没有“对任意方块试一下”的安全泛型语义。粉雪、水源和岩浆源都在效果表里;
719
+ // 其余方块若无明确 handler,库存不变不能再被记作 done。
720
+ if (held === 'bucket') {
721
+ throw new SkillBlocked(`${cellText(cell)} 的${zhName(target.name)}没有空桶可执行的明确操作,没有使用`);
722
+ }
723
+ if (AIR_NAMES.has(target.name) || LIQUIDS.has(target.name)) {
724
+ throw nothingThere(bot, cell, target.name, label);
725
+ }
726
+ noteOffTable(ctx, held, target.name);
727
+ }
728
+ // BucketItem 使用 use_item,由服务端沿视线选液源或放置点;activateBlock 仅发
729
+ // use_item_on,不能完成桶类操作。满桶可倒入空气或实心面的外侧,空桶须满足液源条件。
730
+ const pouring = held !== null && FULL_BUCKETS.has(held);
731
+ // 粉雪没有液体 level,单独走粉雪桶 handler;玻璃瓶装水不使用空桶的源方块契约。
732
+ const scoopingPowderSnow = held === 'bucket' && target.name === 'powder_snow';
733
+ const fillingBottle = held === 'glass_bottle' && target.name === 'water';
734
+ const filling = bucketFluid || scoopingPowderSnow || fillingBottle;
735
+ if (filling || pouring) {
736
+ const beforeScoop = invSnapshot(bot);
737
+ // 瞄点照船那一套:实心格瞄它的顶面(液体落在上面那格),空气/液体格瞄这一格自己
738
+ const solidHere = !AIR_NAMES.has(target.name) && !LIQUIDS.has(target.name);
739
+ await aimThenUse(
740
+ bot,
741
+ solidHere
742
+ ? new Vec3(cell.x + 0.5, cell.y + 1, cell.z + 0.5)
743
+ : new Vec3(cell.x + 0.5, cell.y + 0.5, cell.z + 0.5),
744
+ );
745
+ await sleep(USE_SETTLE_MS);
746
+ const scoopHead = `${label}右键了 ${cellText(cell)} 的${zhName(target.name)}`
747
+ + `${fluidFact ? `(${fluidFact})` : ''}`;
748
+ const scoopNote = useInvNote(beforeScoop, bot);
749
+ // 桶倒空了、水却一格都不留:下界的水放出来当场蒸发。如实说,不拦
750
+ const vapor = held === 'water_bucket' && dimensionOf(bot).includes('nether')
751
+ ? ';水在下界会立刻蒸发,那一格不会留下水'
752
+ : '';
753
+ if (probe) {
754
+ const v = await settleProbe(probe);
755
+ // 失败路径同样带上背包增减:少了这一句,25 次倒水失败她只看得见「空桶 0 → 0」,
756
+ // 看不见「水桶 1 → 1、还在包里」这个把病因指出来的事实
757
+ if (!v.met) {
758
+ throw new SkillBlocked(
759
+ `${scoopHead},${v.actual}${scoopNote ? `。${scoopNote}` : ''}${vapor}`,
760
+ [`要看到的是:${probe.want}`],
761
+ 'server',
762
+ );
763
+ }
764
+ return `${scoopHead},${v.actual}${scoopNote ? `。${scoopNote}` : ''}${vapor}`;
765
+ }
766
+ return `${scoopHead}。${scoopNote || '包里一样没动'}${vapor}`;
767
+ }
768
+ const beforeInv = invSnapshot(bot);
769
+ await bot.activateBlock(target, call.face ? faceVector(call.face) : undefined);
770
+ await sleep(USE_SETTLE_MS);
771
+ // 容器窗口关闭前,将实际读到的内容写入容器账本。
772
+ let seen = '';
773
+ if (bot.currentWindow) {
774
+ seen = rememberWindow(bot, ctx, cell, target.name, bot.currentWindow);
775
+ bot.closeWindow(bot.currentWindow);
776
+ }
777
+ // 床只在主世界能睡:下界与末地点它当场爆炸。不拦她(打龙就是拿这个当伤害手段),
778
+ // 只把这件事说清 —— 现有回执只有「躺下了/没躺下」,读不出人是被自己炸的
779
+ const bedBoom = target.name.endsWith('_bed') && !dimensionOf(bot).includes('overworld')
780
+ ? `;床在${zhDimension(dimensionOf(bot))}这个维度会爆炸,不会躺下`
781
+ : '';
782
+ const head = `${label}右键了 ${cellText(cell)} 的${zhName(target.name)}${bedBoom}`;
783
+ const note = useInvNote(beforeInv, bot);
784
+ if (!probe) {
785
+ // 右键箱子是开一下看看、按钮按下去自己弹回来:原版里这些本来就没有"成没成"
786
+ const after = blockAtCell(bot, cell);
787
+ const changed = after && after.stateId !== target.stateId ? `,那一格现在是${zhName(after.name)}` : '';
788
+ return `${head}${changed}。${note || '包里一样没动'}${seen}`;
789
+ }
790
+ const v = await settleProbe(probe);
791
+ // 失败路径与成功路径报同一份背包增减:存量事实往往就是病因所在
792
+ if (!v.met) {
793
+ throw new SkillBlocked(
794
+ `${head},${v.actual}${note ? `。${note}` : ''}`,
795
+ [`要看到的是:${probe.want}`],
796
+ 'server',
797
+ );
798
+ }
799
+ // 入睡后等待醒来再结束本步,避免后续寻路在睡眠期间取得身体。
800
+ const slept = isSpawnAnchorBlock(target.name) ? await waitForWake(bot, ctx) : '';
801
+ // 锄成耕地的那一格记一笔;再锄同一格说明它被踩回去过(见 tilledOf)
802
+ const retilled = held?.endsWith('_hoe') && target.name !== 'farmland' && HOE_TILLED[target.name] === 'farmland'
803
+ ? noteTilled(bot, cell)
804
+ : '';
805
+ noteWork(bot, ctx, held, cell, target.name);
806
+ return `${head},${v.actual}${slept}${note ? `。${note}` : ''}${seen}${retilled}`;
807
+ }
808
+
809
+ if (!held) throw new SkillBlocked('空手又没给 at/target');
810
+ if (held === 'eye_of_ender') return throwEnderEye(bot, ctx);
811
+ // 手上是吃的/喝的就走真进食通道:通用兜底按一下 1.2 秒就松手,喝完一桶奶要 1.61 秒,
812
+ // 从那条路走的奶永远喝不下去(只会回一句「这样东西没有登记的使用效果」)
813
+ if ((bot.registry?.foodsByName as Record<string, unknown> | undefined)?.[held] || DRINKABLES[held]) {
814
+ return consumeHeldFood(bot, held);
815
+ }
816
+ // 通用使用按整包前后差值回报;无变化时明确无法确认效果。
817
+ const beforeInv = invSnapshot(bot);
818
+ await bot.activateItem();
819
+ await sleep(1_200);
820
+ bot.deactivateItem();
821
+ await sleep(200);
822
+ const note = useInvNote(beforeInv, bot);
823
+ return note
824
+ ? `用了${label};${note}`
825
+ : `拿着${label}按了一下使用;包里一样没动,这样东西没有登记的使用效果,光凭库存读不出有没有发生什么`;
826
+ }
827
+
828
+ /**
829
+ * 扔一颗末影之眼,盯着它飞。原版:它朝要塞方向飞出去再落下,20% 概率碎掉。
830
+ * 现有 `use` 只报「包里少了一个」,而她要的那个读数在飞行途中 —— 飞向哪边、飞多远。
831
+ * 纯观测:一个「往那边走」都不说。
832
+ */
833
+ /**
834
+ * lookAt 先改本地朝向;等待物理 tick 发出朝向包后再发送 use_item。
835
+ * 1.20.6 的 use_item 仅含 hand/sequence,1.21.2 才加入朝向字段。
836
+ * physicsTick 与 updatePosition 同步执行,waitForTicks 续体在整次 tick 结束后恢复。
837
+ */
838
+ export async function aimThenUse(bot: Bot, point: Vec3): Promise<void> {
839
+ await bot.lookAt(point, true);
840
+ await bot.waitForTicks(1);
841
+ await bot.activateItem();
842
+ }
843
+
844
+ /** 原版末影之眼飞 40–80 刻(2–4 秒)就消失,盯 6 秒足够,盯不到就说盯不到 */
845
+ export const ENDER_EYE_WATCH_MS = 6_000;
846
+
847
+ export async function throwEnderEye(bot: Bot, ctx: SkillContext): Promise<string> {
848
+ const from = { x: bot.entity.position.x, y: bot.entity.position.y, z: bot.entity.position.z };
849
+ const before = invCount(bot, (n) => n === 'eye_of_ender');
850
+ await bot.activateItem();
851
+ const deadline = Date.now() + ENDER_EYE_WATCH_MS;
852
+ let last: { x: number; y: number; z: number } | null = null;
853
+ let seen = false;
854
+ while (Date.now() < deadline) {
855
+ checkAbort(ctx);
856
+ const eye = Object.values(bot.entities)
857
+ .find((e) => e?.name === 'eye_of_ender' && e.position);
858
+ if (eye?.position) {
859
+ seen = true;
860
+ last = { x: eye.position.x, y: eye.position.y, z: eye.position.z };
861
+ } else if (seen) break;
862
+ await sleep(150);
863
+ }
864
+ const after = invCount(bot, (n) => n === 'eye_of_ender');
865
+ if (!last) return `扔出去了,但一路没看见那颗末影之眼(包里 ${before} → ${after} 个)`;
866
+ const dist = Math.round(Math.hypot(last.x - from.x, last.z - from.z));
867
+ const dir = bearing(last.x - from.x, last.z - from.z);
868
+ const dy = Math.round(last.y - from.y);
869
+ const drop = Object.values(bot.entities).some(
870
+ (e) => e?.name === 'item' && e.position
871
+ && Math.hypot(e.position.x - last!.x, e.position.y - last!.y, e.position.z - last!.z) <= 4,
872
+ );
873
+ const fell = drop
874
+ ? '落地了,地上有掉落物'
875
+ : '没看见它落地(20% 概率会碎,也可能落在视野外)';
876
+ return `末影之眼朝${dir ? DIRECTION_ZH[dir] : '正上下'}飞了 ${dist} 格`
877
+ + `${dy === 0 ? '' : `,${dy > 0 ? '升' : '降'}了 ${Math.abs(dy)} 格`}`
878
+ + `,最后看见它在 (${Math.round(last.x)}, ${Math.round(last.y)}, ${Math.round(last.z)});${fell}。`
879
+ + `包里 ${before} → ${after} 个`;
880
+ }
881
+
882
+ /**
883
+ * 放一条船:瞄准托住它的那个面按「使用物品」,再按包里少没少、附近多没多出一条船来报。
884
+ *
885
+ * `at` 给空气格就瞄它脚下那格的顶面(船落进 `at` 这一格),给实心格就瞄这一格自己的
886
+ * 顶面(船落在它上面)。两种写法都成立,回执报船最后落在哪儿,不必她先猜对哪一格。
887
+ */
888
+ export async function useBoat(bot: Bot, cell: Cell, held: string, label: string): Promise<string> {
889
+ const here = blockAtCell(bot, cell);
890
+ const solidHere = here !== null && !AIR_NAMES.has(here.name) && !LIQUIDS.has(here.name);
891
+ const face = { x: cell.x, y: solidHere ? cell.y + 1 : cell.y, z: cell.z };
892
+ const under = blockAtCell(bot, { x: face.x, y: face.y - 1, z: face.z });
893
+ if (!under || AIR_NAMES.has(under.name)) {
894
+ throw new SkillBlocked(
895
+ `${cellText(face)} 底下是${under ? zhName(under.name) : '(区块没加载)'},没有能托住船的面`,
896
+ );
897
+ }
898
+ const before = invCount(bot, (n) => n === held);
899
+ await aimThenUse(bot, new Vec3(face.x + 0.5, face.y, face.z + 0.5));
900
+ await sleep(USE_SETTLE_MS);
901
+ const after = invCount(bot, (n) => n === held);
902
+ if (after >= before) {
903
+ throw new SkillBlocked(
904
+ `朝 ${cellText(face)} 放${label},船没出来:包里还是 ${after} 条`,
905
+ [`瞄的是 ${cellText({ x: face.x, y: face.y - 1, z: face.z })} 的${zhName(under.name)}顶面`],
906
+ 'server',
907
+ );
908
+ }
909
+ const boat = nearestBoat(bot, face);
910
+ if (boat) return `${label}放在 ${cellText(boat)};包里 ${before} → ${after} 条`;
911
+ // 不做「包里少了=放成了」的二级软假设:两个读数分开说,落点没读到就是没读到
912
+ return `朝 ${cellText(face)} 放${label}:包里 ${before} → ${after} 条,但附近 4 格内没扫到船的实体,船落在哪儿没读到`;
913
+ }
914
+
915
+ // ======================== ride:上/驾/下坐骑 ========================
916
+
917
+ /** ride 找坐骑的半径,与 use 的活物半径一致 */
918
+ export const RIDE_FIND_R = 32;
919
+ /** 驾驭:到点判定(水平距离) */
920
+ export const RIDE_ARRIVE_R = 2.5;
921
+ /** 驾驭:这么久推进不足 1 格就按卡死收场(下车+如实回执) */
922
+ export const RIDE_STALL_MS = 20_000;
923
+ /** 驾驭:发包节拍(与游戏 tick 同步) */
924
+ export const RIDE_TICK_MS = 50;
925
+ /**
926
+ * 支持驾驭的载具及每物理 tick 的步长,单位为格。
927
+ * 马的位置包受服务端纠偏,暂不支持驾驭;仍支持骑乘和下车。
928
+ */
929
+ export const RIDE_STEP: Readonly<Record<string, number>> = {
930
+ pig: 0.12, strider: 0.12, boat: 0.3, chest_boat: 0.3,
931
+ };
932
+ export const RIDE_STEP_DEFAULT = 0.12;
933
+ /** 驾驭这一种要手持的道具(服务端认「受控」的前提;拿掉它坐骑就不听使唤) */
934
+ export const RIDE_CONTROL_ITEM: Readonly<Record<string, string>> = {
935
+ pig: 'carrot_on_a_stick', strider: 'warped_fungus_on_a_stick',
936
+ };
937
+
938
+ export interface VehicleEntity { name?: string; position: Vec3; height?: number }
939
+ export interface RideClient {
940
+ write(name: string, data: Record<string, unknown>): void;
941
+ on(name: string, fn: (p: { x: number; y: number; z: number }) => void): void;
942
+ removeListener(name: string, fn: (p: { x: number; y: number; z: number }) => void): void;
943
+ }
944
+
945
+ export function vehicleOf(bot: Bot): VehicleEntity | null {
946
+ return (bot as unknown as { vehicle?: VehicleEntity | null }).vehicle ?? null;
947
+ }
948
+
949
+ export async function skillRide(bot: Bot, call: Extract<SkillCall, { skill: 'ride' }>, ctx: SkillContext): Promise<string> {
950
+ if (call.off) {
951
+ const v = vehicleOf(bot);
952
+ if (!v) throw new SkillNoop('没骑着任何东西');
953
+ const name = zhEntity(v.name ?? '坐骑');
954
+ bot.dismount();
955
+ const t0 = Date.now();
956
+ while (vehicleOf(bot) && Date.now() - t0 < 3000) await sleep(100);
957
+ if (vehicleOf(bot)) throw new SkillBlocked(`从${name}上下不来:发了下坐骑的包,3 秒后人还在上面`, [], 'server');
958
+ await sleep(300); // 等服务端把人摆到下车点
959
+ return `从${name}上下来了,人在 ${cellText(feetOf(bot))}`;
960
+ }
961
+
962
+ if (call.target) {
963
+ const riding = vehicleOf(bot);
964
+ if (riding) {
965
+ if ((riding.name ?? '') !== call.target) {
966
+ throw new SkillBlocked(`人还骑在${zhEntity(riding.name ?? '坐骑')}上;先 {"skill":"ride","off":true} 下来再骑别的`);
967
+ }
968
+ } else {
969
+ if (!isKnownTarget(bot, call.target)) throw new SkillBlocked(unknownUseTargetText(bot, call.target));
970
+ const entity = findEntity(bot, call.target, RIDE_FIND_R);
971
+ if (!entity) throw new SkillNoop(`附近 ${RIDE_FIND_R} 格内没有${zhEntity(call.target)}`);
972
+ await gotoGoal(bot, new goals.GoalFollow(entity, 2), ctx).catch(() => undefined);
973
+ checkAbort(ctx);
974
+ if (!entity.isValid) throw new SkillBlocked(`${zhEntity(call.target)}走了`);
975
+ // 手上拿着食物/鞍右键会变成喂食/上鞍,空手骑最稳;驾驭用的钓竿骑上之后再拿
976
+ try { await bot.unequip('hand'); } catch { /* 本来就空手 */ }
977
+ await bot.lookAt(entity.position.offset(0, (entity.height ?? 1) * 0.5, 0));
978
+ bot.mount(entity as never);
979
+ const t0 = Date.now();
980
+ while (!vehicleOf(bot) && Date.now() - t0 < 3000) await sleep(100);
981
+ if (!vehicleOf(bot)) {
982
+ const saddled = readSaddled(bot as never, entity as never);
983
+ const tamed = readHorseTamed(bot as never, entity as never);
984
+ const facts: string[] = [];
985
+ if (saddled !== null) facts.push(`它身上${saddled ? '有' : '没有'}鞍`);
986
+ if (tamed !== null) facts.push(`${tamed ? '驯服过' : '还没驯服(没驯服的马会把人颠下来)'}`);
987
+ throw new SkillBlocked(`右键了${zhEntity(call.target)},3 秒内没坐上去${facts.length > 0 ? `。${facts.join(',')}` : ''}`, [], 'server');
988
+ }
989
+ }
990
+ }
991
+
992
+ const vehicle = vehicleOf(bot);
993
+ if (!vehicle) {
994
+ throw new SkillBlocked('要驾着走得先骑上:同一步给 target,或先来一步 {"skill":"ride","target":"..."}');
995
+ }
996
+ if (!call.to) {
997
+ const saddleNote = readSaddled(bot as never, vehicle as never) === false && RIDE_CONTROL_ITEM[vehicle.name ?? '']
998
+ ? ';它没上鞍,原版没鞍驾驭不了'
999
+ : '';
1000
+ return `骑上${zhEntity(vehicle.name ?? '坐骑')}了(它在 ${cellText(cellOfVec(vehicle.position))})${saddleNote};`
1001
+ + '驾着走用 {"skill":"ride","to":[x,y,z]},下来用 {"skill":"ride","off":true}';
1002
+ }
1003
+ return await rideDrive(bot, ctx, resolveAt(bot, call.to));
1004
+ }
1005
+
1006
+ export function cellOfVec(p: Vec3): Cell {
1007
+ return { x: Math.floor(p.x), y: Math.floor(p.y), z: Math.floor(p.z) };
1008
+ }
1009
+
1010
+ /** 拴绳那件物品的英文 id;与 needs 闸共用同一个常量,不在两处各写一遍字面量 */
1011
+ export const LEAD_ITEM = 'lead';
1012
+ export const LEAD_FIND_R = 32;
1013
+ /** 右键之后等服务端认拴上的窗口 */
1014
+ export const LEAD_ATTACH_MS = 3_000;
1015
+ /**
1016
+ * 一段牵多远。整段直接交给寻路器会把人拉出绳长:原版超 10 格绳就断,而寻路器
1017
+ * 一口气能跑几十格。分段走 + 每段等它跟上,才是「稳定牵到」而不是「走到了但它掉在半路」。
1018
+ */
1019
+ export const LEAD_HOP = 8;
1020
+ /** 原版绳绷断的距离;超过这个数就当断了去核实,不再往前走 */
1021
+ export const LEAD_SNAP = 10;
1022
+ /** 一段走完之后它离我多远就停下等 */
1023
+ export const LEAD_FOLLOW_R = 6;
1024
+ /**
1025
+ * 人自己要走到离终点多近。**不是 tolerance** —— tolerance 是「它到了没有」的验收圈,
1026
+ * 人停在那个圈边上,拖在身后一两格的它就永远差着那一两格进不来。人走到终点上,
1027
+ * 它跟到身后,才落进圈里。
1028
+ */
1029
+ export const LEAD_ARRIVE_R = 1;
1030
+ /** 等它跟上的上限 */
1031
+ export const LEAD_CATCHUP_MS = 8_000;
1032
+ /** 整段牵引的上限 */
1033
+ export const LEAD_DRAG_MS = 150_000;
1034
+
1035
+ /**
1036
+ * 我正牵着的那只。
1037
+ *
1038
+ * 上游把 `attach_entity` 记进了 `entity.vehicle`(见 mineflayer entities.js)——名字是
1039
+ * 历史遗留:1.9 起载具走 `set_passengers`,这个包**只用于拴绳**。所以「它的 vehicle 是我」
1040
+ * 就是「它被我牵着」,这是我们唯一拿得到的服务端拴绳信号。反过来我骑东西时是
1041
+ * `bot.entity.vehicle = 坐骑`,方向相反,不会误判。
1042
+ */
1043
+ export function leashedByMe(bot: Bot): NonNullable<Bot['entities'][string]> | null {
1044
+ const meId = bot.entity?.id;
1045
+ if (meId === undefined) return null;
1046
+ for (const id of Object.keys(bot.entities)) {
1047
+ const e = bot.entities[id];
1048
+ if (!e || e === bot.entity || !e.position) continue;
1049
+ const v = (e as { vehicle?: { id?: number } | null }).vehicle;
1050
+ if (v && v.id === meId) return e;
1051
+ }
1052
+ return null;
1053
+ }
1054
+
1055
+ export function stillLeashed(bot: Bot, e: { id?: number } | null): boolean {
1056
+ const held = leashedByMe(bot);
1057
+ return Boolean(held && e && held.id === e.id);
1058
+ }
1059
+
1060
+ export function leadCount(bot: Bot): number {
1061
+ return invCount(bot, (n) => n === LEAD_ITEM);
1062
+ }
1063
+
1064
+ export function distTo(bot: Bot, p: Vec3 | Cell): number {
1065
+ const me = bot.entity.position;
1066
+ return Math.hypot(me.x - (p as Vec3).x, me.y - (p as Vec3).y, me.z - (p as Vec3).z);
1067
+ }
1068
+
1069
+ /** 栅栏能系绳,栅栏门不能;墙(wall)也不行。名字判据照原版的 fence 族。 */
1070
+ export function isLeashableFence(name: string): boolean {
1071
+ return name.endsWith('_fence') || name === 'nether_brick_fence';
1072
+ }
1073
+
1074
+ /** 拴上:走到跟前、右键、等服务端认。两条独立证据(拴绳少一根 / 它的 vehicle 是我)。 */
1075
+ export async function leadAttach(bot: Bot, target: string, ctx: SkillContext): Promise<{
1076
+ entity: NonNullable<Bot['entities'][string]>;
1077
+ text: string;
1078
+ }> {
1079
+ if (!isKnownTarget(bot, target)) throw new SkillBlocked(unknownUseTargetText(bot, target));
1080
+ if (leadCount(bot) === 0) throw new SkillBlocked('包里没有拴绳(lead):四根线加一颗黏液球搓一根');
1081
+ const entity = findEntity(bot, target, LEAD_FIND_R);
1082
+ if (!entity) throw new SkillNoop(`附近 ${LEAD_FIND_R} 格内没有${zhEntity(target)}`);
1083
+ const already = (entity as { vehicle?: { id?: number } | null }).vehicle;
1084
+ if (already && already.id !== bot.entity?.id) {
1085
+ throw new SkillBlocked(`${zhEntity(target)}身上已经拴着别人的绳了,拴不上第二根`);
1086
+ }
1087
+ await gotoGoal(bot, new goals.GoalFollow(entity, 2), ctx).catch(() => undefined);
1088
+ checkAbort(ctx);
1089
+ if (!entity.isValid) throw new SkillBlocked(`走到跟前时${zhEntity(target)}已经走了`);
1090
+
1091
+ await equipNamed(bot, LEAD_ITEM);
1092
+ const beforeLeads = leadCount(bot);
1093
+ await bot.lookAt(entity.position.offset(0, (entity.height ?? 1) * 0.5, 0));
1094
+ await bot.useOn(entity);
1095
+
1096
+ const until = Date.now() + LEAD_ATTACH_MS;
1097
+ while (Date.now() < until) {
1098
+ checkAbort(ctx);
1099
+ if (stillLeashed(bot, entity)) break;
1100
+ await sleep(100);
1101
+ }
1102
+ const spent = beforeLeads - leadCount(bot);
1103
+ if (!stillLeashed(bot, entity)) {
1104
+ // 绳少了一根但没收到 attach 包 = 服务端接了、我们没看见;绳没少 = 它根本拴不上
1105
+ if (spent > 0) {
1106
+ throw new SkillBlocked(
1107
+ `拴绳少了 ${spent} 根,但 ${LEAD_ATTACH_MS / 1000} 秒内没收到「拴上了」的确认;`
1108
+ + `${zhEntity(target)}现在在 ${cellText(cellOfVec(entity.position))},状态未知`,
1109
+ [], 'server',
1110
+ );
1111
+ }
1112
+ throw new SkillBlocked(
1113
+ `右键了${zhEntity(target)},绳一根没少,没拴上——原版拴不住的有村民、大部分敌对生物、`
1114
+ + '还有已经被别人牵着的',
1115
+ [], 'server',
1116
+ );
1117
+ }
1118
+ return {
1119
+ entity,
1120
+ text: `拴上${zhEntity(target)}了(它在 ${cellText(cellOfVec(entity.position))},拴绳还剩 ${leadCount(bot)} 根)`,
1121
+ };
1122
+ }
1123
+
1124
+ /** 分段牵着走。每段之后核两件事:绳还在不在、它跟上了没有。 */
1125
+ export async function leadDrag(
1126
+ bot: Bot,
1127
+ entity: NonNullable<Bot['entities'][string]>,
1128
+ to: Cell,
1129
+ tolerance: number,
1130
+ ctx: SkillContext,
1131
+ ): Promise<string> {
1132
+ const who = zhEntity(entity.name ?? '它');
1133
+ const goalVec = new Vec3(to.x + 0.5, to.y, to.z + 0.5);
1134
+ const petDist = (): number => (entity.isValid ? entity.position.distanceTo(goalVec) : Infinity);
1135
+ const deadline = Date.now() + LEAD_DRAG_MS;
1136
+ let hops = 0;
1137
+
1138
+ for (;;) {
1139
+ checkAbort(ctx);
1140
+ if (!stillLeashed(bot, entity)) {
1141
+ const where = entity.isValid ? cellText(cellOfVec(entity.position)) : '看不见了';
1142
+ throw new SkillBlocked(
1143
+ `牵到一半绳脱开了(走了 ${hops} 段):${who}在 ${where},我在 ${cellText(feetOf(bot))};`
1144
+ + '绳会掉在脱开的地方,用 pickup 捡回来',
1145
+ [], 'server',
1146
+ );
1147
+ }
1148
+ if (petDist() <= tolerance) break;
1149
+ if (Date.now() > deadline) {
1150
+ throw new SkillBlocked(
1151
+ `牵了 ${hops} 段、${Math.round(LEAD_DRAG_MS / 1000)} 秒还没到:`
1152
+ + `${who}离 ${cellText(to)} 还有 ${petDist().toFixed(1)} 格,我在 ${cellText(feetOf(bot))}`,
1153
+ );
1154
+ }
1155
+
1156
+ const meLeft = distTo(bot, goalVec);
1157
+ if (meLeft > LEAD_ARRIVE_R) {
1158
+ // 一段 = 让寻路器把我带到「离终点还剩 (现在的距离 − LEAD_HOP)」的那个圈上,
1159
+ // 到不了终点就停在半路。不自己造中途路点,免得点落在山体/空中
1160
+ const ring = Math.max(LEAD_ARRIVE_R, Math.ceil(meLeft) - LEAD_HOP);
1161
+ await gotoGoal(bot, new goals.GoalNear(to.x, to.y, to.z, ring), ctx).catch(() => undefined);
1162
+ hops++;
1163
+ checkAbort(ctx);
1164
+
1165
+ // 等它跟上。绳是软的,人站着不动它自己会走过来
1166
+ const wait = Date.now() + LEAD_CATCHUP_MS;
1167
+ while (Date.now() < wait) {
1168
+ checkAbort(ctx);
1169
+ if (!stillLeashed(bot, entity)) break;
1170
+ const gap = entity.position.distanceTo(bot.entity.position);
1171
+ if (gap <= LEAD_FOLLOW_R) break;
1172
+ if (gap > LEAD_SNAP) break; // 已经超过原版绳长,下一轮的脱开判据会说清
1173
+ await sleep(200);
1174
+ }
1175
+
1176
+ // 这一段一步没推进 —— 路被堵死,再走多少段也是同一个结果
1177
+ if (meLeft - distTo(bot, goalVec) < 0.5) {
1178
+ const gap = entity.isValid ? entity.position.distanceTo(bot.entity.position).toFixed(1) : '?';
1179
+ throw new SkillBlocked(
1180
+ `牵不动了(走了 ${hops} 段,这一段一步没推进):我在 ${cellText(feetOf(bot))}、`
1181
+ + `${who}在我 ${gap} 格外,离 ${cellText(to)} 还有 ${petDist().toFixed(1)} 格`,
1182
+ );
1183
+ }
1184
+ continue;
1185
+ }
1186
+
1187
+ // 人已经站在终点上了,只差它。这一段把等待走满,它还进不来就是它不肯再近
1188
+ const wait = Date.now() + LEAD_CATCHUP_MS;
1189
+ while (Date.now() < wait && stillLeashed(bot, entity) && petDist() > tolerance) {
1190
+ checkAbort(ctx);
1191
+ await sleep(200);
1192
+ }
1193
+ if (stillLeashed(bot, entity) && petDist() > tolerance) {
1194
+ const gap = entity.position.distanceTo(bot.entity.position).toFixed(1);
1195
+ throw new SkillBlocked(
1196
+ `我已经站在 ${cellText(to)} 了,${who}跟到我 ${gap} 格外就不再近,`
1197
+ + `离目标还有 ${petDist().toFixed(1)} 格、超出 tolerance ${tolerance} 格;`
1198
+ + '把 tolerance 放宽,或者直接给 tie 系到旁边的栅栏上',
1199
+ );
1200
+ }
1201
+ }
1202
+ return `把${who}牵到 ${cellText(to)} 了(它现在离目标 ${petDist().toFixed(1)} 格,${tolerance} 格内算到),走了 ${hops} 段`;
1203
+ }
1204
+
1205
+ /** 松开:空手右键它。绳会掉在地上,照实说要捡。 */
1206
+ export async function leadRelease(bot: Bot, entity: NonNullable<Bot['entities'][string]>, ctx: SkillContext): Promise<string> {
1207
+ const who = zhEntity(entity.name ?? '它');
1208
+ try { await bot.unequip('hand'); } catch { /* 本来就空手 */ }
1209
+ checkAbort(ctx);
1210
+ await bot.lookAt(entity.position.offset(0, (entity.height ?? 1) * 0.5, 0));
1211
+ await bot.useOn(entity);
1212
+ const until = Date.now() + LEAD_ATTACH_MS;
1213
+ while (Date.now() < until && stillLeashed(bot, entity)) {
1214
+ checkAbort(ctx);
1215
+ await sleep(100);
1216
+ }
1217
+ if (stillLeashed(bot, entity)) {
1218
+ throw new SkillBlocked(`空手右键了${who},${LEAD_ATTACH_MS / 1000} 秒后绳还牵着`, [], 'server');
1219
+ }
1220
+ return `松开${who}了,它留在 ${cellText(cellOfVec(entity.position))};绳掉在那儿,用 {"skill":"pickup","item":"lead"} 捡`;
1221
+ }
1222
+
1223
+ /** 系到栅栏上:走到栅栏边右键它。系上之后它的绳头就不在我手里了。 */
1224
+ export async function leadTie(
1225
+ bot: Bot,
1226
+ entity: NonNullable<Bot['entities'][string]>,
1227
+ tie: Cell,
1228
+ ctx: SkillContext,
1229
+ ): Promise<string> {
1230
+ const who = zhEntity(entity.name ?? '它');
1231
+ const fence = blockAtCell(bot, tie);
1232
+ if (!fence) throw new SkillBlocked(`${cellText(tie)} 区块没加载,看不到那儿是什么`);
1233
+ if (!isLeashableFence(fence.name)) {
1234
+ throw new SkillBlocked(
1235
+ `${cellText(tie)} 是${zhName(fence.name)},绳系不上去——原版只有栅栏能系(栅栏门也不行)`,
1236
+ );
1237
+ }
1238
+ await gotoGoal(bot, new goals.GoalNear(tie.x, tie.y, tie.z, 2), ctx).catch(() => undefined);
1239
+ checkAbort(ctx);
1240
+ if (!stillLeashed(bot, entity)) {
1241
+ throw new SkillBlocked(`走到栅栏边时绳已经脱开了,${who}没系上`, [], 'server');
1242
+ }
1243
+ await bot.lookAt(new Vec3(tie.x + 0.5, tie.y + 0.5, tie.z + 0.5));
1244
+ await bot.activateBlock(fence);
1245
+ const until = Date.now() + LEAD_ATTACH_MS;
1246
+ while (Date.now() < until && stillLeashed(bot, entity)) {
1247
+ checkAbort(ctx);
1248
+ await sleep(100);
1249
+ }
1250
+ if (stillLeashed(bot, entity)) {
1251
+ throw new SkillBlocked(
1252
+ `右键了 ${cellText(tie)} 的${zhName(fence.name)},${LEAD_ATTACH_MS / 1000} 秒后绳头还在我手里,没系上`,
1253
+ [], 'server',
1254
+ );
1255
+ }
1256
+ const where = entity.isValid ? cellText(cellOfVec(entity.position)) : '看不见了';
1257
+ return `把${who}系到 ${cellText(tie)} 的${zhName(fence.name)}上了,它现在在 ${where},跑不远了`;
1258
+ }
1259
+
1260
+ /**
1261
+ * 拴绳:拴上 → 分段牵到 → 松开/系栅栏。三段各自可单独成立(她可以分步下单)。
1262
+ *
1263
+ * 为什么要分段走:原版绳超 10 格就断,而寻路器一口气跑几十格。整段丢给 goto 的结果
1264
+ * 是「人到了、它断在半路」——那正是这个技能要解决的事,所以分段与等它跟上是本体,
1265
+ * 不是保守起见的额外保护。
1266
+ */
1267
+ export async function skillLead(bot: Bot, call: Extract<SkillCall, { skill: 'lead' }>, ctx: SkillContext): Promise<string> {
1268
+ if (call.off) {
1269
+ const held = leashedByMe(bot);
1270
+ if (!held) throw new SkillNoop('现在没牵着任何活物');
1271
+ return await leadRelease(bot, held, ctx);
1272
+ }
1273
+
1274
+ const parts: string[] = [];
1275
+ let entity = leashedByMe(bot);
1276
+ if (call.target) {
1277
+ if (entity && (entity.name ?? '') !== call.target) {
1278
+ throw new SkillBlocked(
1279
+ `手里还牵着${zhEntity(entity.name ?? '一只活物')};先 {"skill":"lead","off":true} 松开再拴别的`,
1280
+ );
1281
+ }
1282
+ if (!entity) {
1283
+ const got = await leadAttach(bot, call.target, ctx);
1284
+ entity = got.entity;
1285
+ parts.push(got.text);
1286
+ }
1287
+ }
1288
+ if (!entity) {
1289
+ throw new SkillBlocked('手里没牵着东西:同一步给 target,或先来一步 {"skill":"lead","target":"..."}');
1290
+ }
1291
+
1292
+ if (call.to) {
1293
+ parts.push(await leadDrag(bot, entity, resolveAt(bot, call.to), call.tolerance ?? 3, ctx));
1294
+ }
1295
+ if (call.tie) {
1296
+ parts.push(await leadTie(bot, entity, resolveAt(bot, call.tie), ctx));
1297
+ } else if (call.to && !call.keep) {
1298
+ parts.push(await leadRelease(bot, entity, ctx));
1299
+ }
1300
+ return parts.join(';');
1301
+ }
1302
+
1303
+ /**
1304
+ * 落地那一格的 y:从当前高度 +1 往下扫到 -3,找「脚下实心、本格与头上非实心」。
1305
+ * 找不到(区块没加载/前面是墙或深坑)返回 null,由调用方按停滞收场。
1306
+ */
1307
+ export function rideGroundY(bot: Bot, x: number, yNow: number, z: number): number | null {
1308
+ const fx = Math.floor(x);
1309
+ const fz = Math.floor(z);
1310
+ const yBase = Math.floor(yNow);
1311
+ for (let dy = 1; dy >= -3; dy -= 1) {
1312
+ const yy = yBase + dy;
1313
+ const here = blockAtCell(bot, { x: fx, y: yy, z: fz });
1314
+ const below = blockAtCell(bot, { x: fx, y: yy - 1, z: fz });
1315
+ if (!here || !below) return null;
1316
+ const standable = !AIR_NAMES.has(below.name) && !LIQUIDS.has(below.name);
1317
+ if (AIR_NAMES.has(here.name) && standable) return yy;
1318
+ // 船浮在水格上沿;已在水面时沿用当前高度,从岸上入水时使用水面上沿高度。
1319
+ if (AIR_NAMES.has(here.name) && below.name === 'water') return Math.min(yNow, yy);
1320
+ if (here.name === 'water') return yy + 1;
1321
+ }
1322
+ return null;
1323
+ }
1324
+
1325
+ /**
1326
+ * 玩家控制的载具由骑手客户端发送 vehicle_move 绝对坐标,服务端做碰撞和纠偏。
1327
+ * Mineflayer 无载具物理,此处每 tick 小步移动并转向;仅转头不能驱动载具。
1328
+ */
1329
+ export async function rideDrive(bot: Bot, ctx: SkillContext, to: Cell): Promise<string> {
1330
+ const vehicle = vehicleOf(bot);
1331
+ if (!vehicle) throw new SkillBlocked('人不在坐骑上');
1332
+ const vname = vehicle.name ?? '';
1333
+ const zhV = zhEntity(vname || '坐骑');
1334
+ if (RIDE_STEP[vname] === undefined) {
1335
+ throw new SkillBlocked(
1336
+ `${zhV}骑得上,但驾着走这版还不支持(位置协议在测试服务器上没验过关);`
1337
+ + '能驾的是猪(要鞍+胡萝卜钓竿)和船;下来用 {"skill":"ride","off":true}',
1338
+ );
1339
+ }
1340
+ const control = RIDE_CONTROL_ITEM[vname];
1341
+ if (control) {
1342
+ try {
1343
+ await equipNamed(bot, control);
1344
+ } catch (err) {
1345
+ throw new SkillBlocked(`驾${zhV}要手持${zhName(control)}:${(err as Error).message};不拿它${zhV}不听使唤`);
1346
+ }
1347
+ if (readSaddled(bot as never, vehicle as never) === false) {
1348
+ throw new SkillBlocked(`这只${zhV}没上鞍,原版没鞍驾驭不了;先 {"skill":"use","item":"saddle","target":"${vname}"} 上鞍`);
1349
+ }
1350
+ }
1351
+ const step = RIDE_STEP[vname] ?? RIDE_STEP_DEFAULT;
1352
+ const client = (bot as unknown as { _client: RideClient })._client;
1353
+ const pos = vehicle.position.clone();
1354
+ const start = { x: pos.x, z: pos.z };
1355
+ let corrections = 0;
1356
+ const onCorrect = (p: { x: number; y: number; z: number }): void => {
1357
+ corrections += 1;
1358
+ pos.set(p.x, p.y, p.z);
1359
+ };
1360
+ client.on('vehicle_move', onCorrect);
1361
+ const startedAt = Date.now();
1362
+ const startDist = Math.hypot(to.x + 0.5 - pos.x, to.z + 0.5 - pos.z);
1363
+ // 3 倍标称速度余量 + 15s 底;驾驭不该比走路更能耗时间
1364
+ const capMs = 15_000 + (startDist / (step * 20)) * 3_000;
1365
+ let mark = { x: pos.x, z: pos.z, at: startedAt };
1366
+ let stalledWhy: string | null = null;
1367
+ try {
1368
+ for (;;) {
1369
+ checkAbort(ctx);
1370
+ if (!vehicleOf(bot)) { stalledWhy = '人从坐骑上掉下来了(服务端把人卸了下来)'; break; }
1371
+ const dx = to.x + 0.5 - pos.x;
1372
+ const dz = to.z + 0.5 - pos.z;
1373
+ const dist = Math.hypot(dx, dz);
1374
+ if (dist <= RIDE_ARRIVE_R) break;
1375
+ if (Date.now() - startedAt > capMs) {
1376
+ stalledWhy = `跑满了时限(${Math.round(capMs / 1000)}s)还没到`;
1377
+ break;
1378
+ }
1379
+ if (Date.now() - mark.at >= RIDE_STALL_MS) {
1380
+ const moved = Math.hypot(pos.x - mark.x, pos.z - mark.z);
1381
+ if (moved < 1) {
1382
+ stalledWhy = `${Math.round(RIDE_STALL_MS / 1000)} 秒只挪了 ${moved.toFixed(1)} 格(前面多半有墙/深坑)`;
1383
+ break;
1384
+ }
1385
+ mark = { x: pos.x, z: pos.z, at: Date.now() };
1386
+ }
1387
+ const ux = dx / dist;
1388
+ const uz = dz / dist;
1389
+ const nx = pos.x + ux * Math.min(step, dist);
1390
+ const nz = pos.z + uz * Math.min(step, dist);
1391
+ const ny = rideGroundY(bot, nx, pos.y, nz);
1392
+ if (ny === null) {
1393
+ stalledWhy = '前面那一格落不了脚(悬崖/墙/区块没加载)';
1394
+ break;
1395
+ }
1396
+ // notchian yaw:0=+Z,-90=+X
1397
+ const yaw = -Math.atan2(ux, uz) * (180 / Math.PI);
1398
+ client.write('look', { yaw, pitch: 0, onGround: false });
1399
+ client.write('vehicle_move', { x: nx, y: ny, z: nz, yaw, pitch: 0 });
1400
+ pos.set(nx, ny, nz);
1401
+ vehicle.position.set(nx, ny, nz);
1402
+ // 骑手位置跟着坐骑走:别的读数(距离、快照)不该停在上马那一格
1403
+ bot.entity.position.set(nx, ny + 0.6, nz);
1404
+ await sleep(RIDE_TICK_MS);
1405
+ }
1406
+ } finally {
1407
+ client.removeListener('vehicle_move', onCorrect);
1408
+ }
1409
+ const ridden = Math.round(Math.hypot(pos.x - start.x, pos.z - start.z));
1410
+ const secs = Math.round((Date.now() - startedAt) / 1000);
1411
+ const where = cellText(cellOfVec(pos));
1412
+ const left = Math.round(Math.hypot(to.x + 0.5 - pos.x, to.z + 0.5 - pos.z));
1413
+ const fixNote = corrections > 0 ? `;路上服务端纠了 ${corrections} 次位置` : '';
1414
+ if (stalledWhy) {
1415
+ try { bot.dismount(); } catch { /* 已不在坐骑上 */ }
1416
+ await sleep(500);
1417
+ throw new SkillBlocked(
1418
+ `骑着${zhV}走了 ${ridden} 格停在 ${where},离目标还 ${left} 格:${stalledWhy};已经下来了${fixNote}`,
1419
+ [], 'server',
1420
+ );
1421
+ }
1422
+ return `骑着${zhV}到了 ${where}(走了 ${ridden} 格、${secs}s,离目标 ${left} 格),人还骑着;下来用 {"skill":"ride","off":true}${fixNote}`;
1423
+ }
1424
+
1425
+ // ======================== anvil / grindstone:通用窗口协议 ========================
1426
+
1427
+ export async function skillAnvil(bot: Bot, call: Extract<SkillCall, { skill: 'anvil' }>, ctx: SkillContext): Promise<string> {
1428
+ const cell = call.at ? resolveAt(bot, call.at) : findStationCell(bot, ANVIL_BLOCKS);
1429
+ if (!cell) throw new SkillBlocked(`附近 ${STATION_FIND_R} 格内没有铁砧;放一个再来,或用 at 指一格`);
1430
+ const reg = bot.registry as never;
1431
+ const mainPred: InvPred = (n, it) => n === call.item && itemMatchesPick(call.pick, it, reg);
1432
+ const withPred: InvPred = (n, it) => n === call.with && itemMatchesPick(call.withPick, it, reg);
1433
+ const main = bot.inventory.items().find((i) => mainPred(i.name, i));
1434
+ if (!main) {
1435
+ throw noSuchItem(bot, call.item, call.pick, bot.inventory.items().filter((i) => i.name === call.item));
1436
+ }
1437
+ const withOne = call.with === undefined
1438
+ ? null
1439
+ : bot.inventory.items().find((i) => withPred(i.name, i)) ?? null;
1440
+ if (call.op !== 'rename') {
1441
+ // 两件同种合修:左右两格不能是同一件,所以要两件都符合
1442
+ const twoOfAKind = call.with === call.item && call.pick === call.withPick;
1443
+ const matched = bot.inventory.items().filter((i) => withPred(i.name, i)).length;
1444
+ if (matched < (twoOfAKind ? 2 : 1)) {
1445
+ throw twoOfAKind
1446
+ ? new SkillBlocked(`包里只有一件${itemAsked(call.item, call.pick)},两件同种才能合`)
1447
+ : noSuchItem(bot, call.with!, call.withPick, bot.inventory.items().filter((i) => i.name === call.with));
1448
+ }
1449
+ }
1450
+ const lvl0 = bot.experience.level;
1451
+ const before = invSnapshot(bot);
1452
+ // 这一单要几级:铁砧窗口的 property 0(craft_progress_bar 包)
1453
+ let costShown: number | null = null;
1454
+ const client = (bot as unknown as { _client: RideClient })._client;
1455
+ const onProp = (p: { property?: number; value?: number } & { x: number; y: number; z: number }): void => {
1456
+ if (p.property === 0 && typeof p.value === 'number' && p.value > 0) costShown = p.value;
1457
+ };
1458
+ (client as unknown as { on(n: string, f: unknown): void }).on('craft_progress_bar', onProp);
1459
+ const { win, blockName } = await openStationWindow(bot, ctx, cell, ANVIL_BLOCKS, '铁砧');
1460
+ let out: { name: string } | null = null;
1461
+ try {
1462
+ await putIntoStation(bot, win, mainPred, 0, itemAsked(call.item, call.pick));
1463
+ await sleep(300);
1464
+ if (call.op !== 'rename') {
1465
+ await putIntoStation(bot, win, withPred, 1, itemAsked(call.with!, call.withPick));
1466
+ } else {
1467
+ // 原版改名:客户端敲字发 name_item,服务端据此填产出槽
1468
+ client.write('name_item', { name: call.name ?? '' });
1469
+ }
1470
+ await sleep(WINDOW_SETTLE_MS);
1471
+ out = win.slots[2] as { name: string } | null;
1472
+ if (!out) {
1473
+ throw new SkillBlocked(
1474
+ call.op === 'rename'
1475
+ ? `铁砧不认这个名字:产出槽没出东西(写的是「${call.name}」)`
1476
+ : `铁砧的产出槽没出东西:${askedLabel(bot, call.item, call.pick, main)}`
1477
+ + `+${askedLabel(bot, call.with!, call.withPick, withOne)} 这一对按原版做不出结果`,
1478
+ [], 'server',
1479
+ );
1480
+ }
1481
+ const outFacts = stationItemFacts(bot, out);
1482
+ await (bot as unknown as { clickWindow(s: number, b: number, m: number): Promise<void> }).clickWindow(2, 0, 1);
1483
+ await sleep(WINDOW_SETTLE_MS);
1484
+ // 产出留在读数里,取没取到由下面的等级/库存判
1485
+ void outFacts;
1486
+ } finally {
1487
+ (client as unknown as { removeListener(n: string, f: unknown): void }).removeListener('craft_progress_bar', onProp);
1488
+ try { bot.closeWindow(win as never); } catch { /* 已关 */ }
1489
+ }
1490
+ await sleep(400);
1491
+ const lvl1 = bot.experience.level;
1492
+ const spent = lvl0 - lvl1;
1493
+ if (spent <= 0) {
1494
+ const gate = costShown !== null ? `这一单显示要 ${costShown} 级,你现在 ${lvl1} 级` : `你现在 ${lvl1} 级`;
1495
+ const changed = invGains(before, bot).length > 0 || invLosses(before, bot).length > 0;
1496
+ if (!changed) {
1497
+ throw new SkillBlocked(`铁砧的产出没拿到手:经验一级没扣、包里一样没动(${gate};等级不够时原版不给取)`, [], 'server');
1498
+ }
1499
+ }
1500
+ const result = bot.inventory.items().find((i) => i.name === (out?.name ?? call.item));
1501
+ const anvilNow = blockAtCell(bot, cell)?.name ?? null;
1502
+ const wear = anvilNow === blockName
1503
+ ? ''
1504
+ : anvilNow && (ANVIL_BLOCKS as readonly string[]).includes(anvilNow)
1505
+ ? `;铁砧磨损了一级(现在是${zhName(anvilNow)})`
1506
+ : ';铁砧这一下用碎了,那一格已经空了';
1507
+ const head = call.op === 'rename'
1508
+ ? `在 ${cellText(cell)} 的${zhName(blockName)}上把${askedLabel(bot, call.item, call.pick, main)}改名成「${call.name}」`
1509
+ : `在 ${cellText(cell)} 的${zhName(blockName)}上把${askedLabel(bot, call.item, call.pick, main)}`
1510
+ + `和${askedLabel(bot, call.with!, call.withPick, withOne)}合了`;
1511
+ return `${head}:产物${result ? `${zhName(result.name)}(${stationItemFacts(bot, result)})` : '已入包'};`
1512
+ + `花了 ${Math.max(spent, 0)} 级经验(${lvl0} → ${lvl1})${wear}`;
1513
+ }
1514
+
1515
+ export async function skillGrindstone(bot: Bot, call: Extract<SkillCall, { skill: 'grindstone' }>, ctx: SkillContext): Promise<string> {
1516
+ const cell = call.at ? resolveAt(bot, call.at) : findStationCell(bot, ['grindstone']);
1517
+ if (!cell) throw new SkillBlocked(`附近 ${STATION_FIND_R} 格内没有砂轮;放一个再来,或用 at 指一格`);
1518
+ const reg = bot.registry as never;
1519
+ const mainPred: InvPred = (n, it) => n === call.item && itemMatchesPick(call.pick, it, reg);
1520
+ const withPred: InvPred = (n, it) => n === call.with && itemMatchesPick(call.withPick, it, reg);
1521
+ const main = bot.inventory.items().find((i) => mainPred(i.name, i));
1522
+ if (!main) {
1523
+ throw noSuchItem(bot, call.item, call.pick, bot.inventory.items().filter((i) => i.name === call.item));
1524
+ }
1525
+ const beforeFacts = stationItemFacts(bot, main);
1526
+ const pts0 = bot.experience.points;
1527
+ const { win } = await openStationWindow(bot, ctx, cell, ['grindstone'], '砂轮');
1528
+ let out: { name: string } | null = null;
1529
+ try {
1530
+ await putIntoStation(bot, win, mainPred, 0, itemAsked(call.item, call.pick));
1531
+ if (call.with) {
1532
+ await sleep(200);
1533
+ await putIntoStation(bot, win, withPred, 1, itemAsked(call.with, call.withPick));
1534
+ }
1535
+ await sleep(WINDOW_SETTLE_MS);
1536
+ out = win.slots[2] as { name: string } | null;
1537
+ if (!out) {
1538
+ throw new SkillBlocked(
1539
+ call.with
1540
+ ? `砂轮的产出槽没出东西:${askedLabel(bot, call.item, call.pick, main)}`
1541
+ + `+${itemAsked(call.with, call.withPick)} 这一对按原版磨不出结果(两件得是同种工具)`
1542
+ : `砂轮的产出槽没出东西:${askedLabel(bot, call.item, call.pick, main)}按原版磨不出结果`,
1543
+ [], 'server',
1544
+ );
1545
+ }
1546
+ await (bot as unknown as { clickWindow(s: number, b: number, m: number): Promise<void> }).clickWindow(2, 0, 1);
1547
+ await sleep(WINDOW_SETTLE_MS);
1548
+ } finally {
1549
+ try { bot.closeWindow(win as never); } catch { /* 已关 */ }
1550
+ }
1551
+ await sleep(400); // 经验球飞过来要一拍
1552
+ const result = bot.inventory.items().find((i) => i.name === (out?.name ?? call.item));
1553
+ const gained = bot.experience.points - pts0;
1554
+ const xpNote = gained > 0 ? `;返还了 ${gained} 点经验(附魔按原版比例折算)` : ';没有经验返还';
1555
+ return `在 ${cellText(cell)} 的砂轮上磨了${askedLabel(bot, call.item, call.pick, main)}`
1556
+ + `${call.with ? `+${itemAsked(call.with, call.withPick)}` : ''}:`
1557
+ + `磨之前(${beforeFacts}),磨完(${result ? stationItemFacts(bot, result) : '产物读不到'})${xpNote}`;
1558
+ }
1559
+