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,1230 @@
1
+ /**
2
+ * 找东西与拿东西:collect、find、fish、trade、probe。
3
+ *
4
+ * 共同点是先看见再动手,看不见的一律照实报,不靠穿墙情报凑答案。
5
+ */
6
+ import type { Bot } from 'mineflayer';
7
+ import { Vec3 } from 'vec3';
8
+ import {
9
+ CROP_MAX_AGE, DIRECTIONS, DIRECTION_ZH, bearing, biomeAt, canSeeBlockAt, canSeeEntity, cropAgeAt,
10
+ isNight, type Direction, villagerNote,
11
+ } from './terrain.ts';
12
+ import { Aborted, SkillBlocked, checkAbort, sleep, type SkillContext } from './skill-context.ts';
13
+ import {
14
+ digBlock, dropGoal, gotoGoal, levelTravelGoal, matchBlockIds, routeNote, withRouteScene,
15
+ } from './travel.ts';
16
+ import {
17
+ INVENTORY_SLOTS, PICKUP_SETTLE_MS, dropNamesOf, invCount, invGains, invSnapshot,
18
+ } from './inventory.ts';
19
+ import { Upkeep, pushPocketLine, standableCell } from './placement.ts';
20
+ import { zhEntity, zhName } from './names.ts';
21
+ import { equipToolFor, harvestFact, miningToolPlan } from './tools.ts';
22
+ import { bestWeapon } from './melee.ts';
23
+ import { type FindKind } from './search-observation.ts';
24
+ import { type Cell } from './geometry.ts';
25
+ import {
26
+ AIR_NAMES, LIQUIDS, PROBE_CELLWISE_MAX, PROBE_CELL_CAP, PROBE_WHERE_CELL_CAP, SHAPE_ZH,
27
+ blockAtCell, blockNamesOf, blockProp, cellText, cropAgeOfCell, dimensionOf, feetOf, fnv32,
28
+ readRegion, resolveAt, shapeCells, skyBlocked,
29
+ } from './cell-facts.ts';
30
+ import {
31
+ FIND_STATIC_MAX, PROBE_WHERE_SHOWN, UNTIL_CATEGORIES, UNTIL_CATEGORY_DOC, type SkillCall,
32
+ } from './skills.ts';
33
+ import { compositionText, zhErrorText, zhThing } from './receipt.ts';
34
+ import { UNTIL_TRAVEL_RADIUS, type UntilHit, untilBlockIds, untilHit, untilUnknownNote } from './until.ts';
35
+ import { PLAYER_SLOTS } from './precheck.ts';
36
+ import pathfinderPkg from 'mineflayer-pathfinder';
37
+
38
+ const { goals } = pathfinderPkg;
39
+
40
+ /**
41
+ * 目标其实是掉落物的常见错位:世界里几乎不会自然生成这些方块,agent 点名采集时
42
+ * 找不着/够不着,真相是"去挖来源方块"。受阻回执把这条机械事实带上(不替它决策)。
43
+ */
44
+ export const DROP_SOURCE_HINT: Record<string, string> = {
45
+ cobblestone: '圆石不是自然生成的方块,是用镐挖石头掉出来的',
46
+ cobbled_deepslate: '深板岩圆石不是自然生成的方块,是用镐挖深板岩掉出来的',
47
+ };
48
+
49
+ /**
50
+ * collect 在 findBlocks 结果上执行视线检查;区块索引查询本身不检查遮挡。
51
+ * 4.5 格内、非 air 的空碰撞形状方块可直接视为可见,避免射线穿过草花命中后方地面。
52
+ */
53
+ export function collectVisible(bot: Bot, p: { x: number; y: number; z: number }): boolean {
54
+ if (canSeeBlockAt(bot, p)) return true;
55
+ const b = bot.blockAt(p as never);
56
+ if (!b || b.boundingBox !== 'empty' || b.name === 'air') return false;
57
+ const me = bot.entity.position;
58
+ return Math.hypot(me.x - (p.x + 0.5), me.y - (p.y + 0.5), me.z - (p.z + 0.5)) <= 4.5;
59
+ }
60
+
61
+ export async function skillCollect(
62
+ bot: Bot,
63
+ block: string,
64
+ count: number,
65
+ ctx: SkillContext,
66
+ buried = false,
67
+ mature = false,
68
+ tool?: string,
69
+ ): Promise<string> {
70
+ const ids = matchBlockIds(bot, block);
71
+ if (ids.length === 0) throw new SkillBlocked(categoryScopeText(block) ?? `不认识「${block}」这种方块`);
72
+ const sourceHint = DROP_SOURCE_HINT[block];
73
+ const isTarget = (n: string) => ids.some((id) => (bot.registry.blocks as Record<number, { name: string }>)[id]?.name === n);
74
+ const gains = dropNamesOf(bot, ids);
75
+ const countGains = (): number => invCount(bot, (n) => gains.has(n));
76
+ const before = countGains();
77
+ let dug = 0;
78
+ let ranOut = false;
79
+ /** mature 提前收手时还剩几格没长成的:收工回执照实带上 */
80
+ let ranOutImmature = 0;
81
+ // 挖掘等级:选定目标那一刻(还没起步)就读得出的一句事实,进这一步的每一份回执。
82
+ // undefined = 还没读过,null = 读过且够级。
83
+ let toolFact: string | null | undefined;
84
+ // buried 通道的打洞记账:同一处挖到跟前还看不见就别再打,不无限打洞
85
+ let tunnels = 0;
86
+ let lastTunnelKey: string | null = null;
87
+ // 采集途中的补光:lightWhen:"anywhere" 才做,默认这条不响
88
+ const keep = new Upkeep(bot, ctx);
89
+ const litTail = (): string => {
90
+ const t = keep.tally();
91
+ return t.length > 0 ? `;${t.join(';')}` : '';
92
+ };
93
+ for (let i = 0; i < count; i++) {
94
+ checkAbort(ctx);
95
+ // 只挖看得见的。扫到多少与看得见多少分开记:前者不进回执(那是穿墙情报),
96
+ // 后者才是她的感知面。
97
+ const scanned = bot.findBlocks({ matching: ids, maxDistance: 48, count: mature ? 64 : 16 });
98
+ const found = scanned.filter((q) => collectVisible(bot, q));
99
+ let pos: (typeof found)[number] | undefined = found[0];
100
+ // 只收成熟项时按 age 达上限筛选,未成熟格保留。
101
+ let immature = 0;
102
+ let bestAge: { value: number; max: number } | null = null;
103
+ let noAge = 0;
104
+ if (mature) {
105
+ pos = undefined;
106
+ for (const p of found) {
107
+ const age = cropAgeAt(bot, p);
108
+ if (age === null) { noAge++; continue; }
109
+ if (age.value >= age.max) { pos = p; break; }
110
+ immature++;
111
+ if (!bestAge || age.value > bestAge.value) bestAge = age;
112
+ }
113
+ }
114
+ if (!pos) {
115
+ if (dug === 0) {
116
+ if (immature > 0) {
117
+ throw new SkillBlocked(
118
+ `48 格内的${zhName(block)}都还没长成(看得见 ${immature} 格,最高 age ${bestAge!.value}/${bestAge!.max}),没动它们`,
119
+ );
120
+ }
121
+ if (mature && noAge > 0) {
122
+ throw new SkillBlocked(`${zhName(block)}没有 age 状态,"只收熟的"用不上;去掉 mature 就照常挖`);
123
+ }
124
+ const scene: string[] = [];
125
+ if (lastTunnelKey) scene.push(`已经挖到 (${lastTunnelKey}) 跟前,那儿也没有`);
126
+ // 「扫得到但一处都看不见」与「压根没有」是两件事,措辞分开;但都不报
127
+ // 看不见那些的坐标与处数——那正是要收掉的穿墙情报。
128
+ throw new SkillBlocked(
129
+ `附近看不见${mature ? '熟着的' : ''}${zhName(block)}` +
130
+ `${sourceHint ? `。${sourceHint}` : ''}。${findEmptyHint(bot, ctx, block, false)}`,
131
+ scene,
132
+ );
133
+ }
134
+ if (immature > 0) ranOutImmature = immature;
135
+ ranOut = true;
136
+ break;
137
+ }
138
+ if (toolFact === undefined) {
139
+ // 出发前选工具并读取挖掘等级,提前提供掉落条件。
140
+ const first = bot.blockAt(pos);
141
+ if (first) {
142
+ await equipToolFor(bot, first, ctx, miningToolPlan(tool));
143
+ toolFact = harvestFact(bot, first);
144
+ }
145
+ }
146
+ try {
147
+ // GoalLookAtBlock 的射线无法命中空碰撞形状;这类目标按距离走近,
148
+ // 由 digBlock 的 canDigBlock 按服务端 5.1 格可及判据决定能否挖掘。
149
+ const goal = bot.blockAt(pos)?.boundingBox === 'empty'
150
+ ? new goals.GoalNear(pos.x, pos.y, pos.z, 2)
151
+ : new goals.GoalLookAtBlock(pos, bot.world, { reach: 4 });
152
+ await gotoGoal(bot, goal, ctx);
153
+ } catch (err) {
154
+ if (err instanceof Aborted) throw err;
155
+ // "buried":true = 她读过受阻现场之后的显式决定:看得见但走不过去,就挖条路过去。
156
+ // 它管的是"够不够得着",不是"看不看得见"——目标早在上面过了视线闸。
157
+ const key = `${pos.x},${pos.y},${pos.z}`;
158
+ if (buried && tunnels < 4 && lastTunnelKey !== key) {
159
+ lastTunnelKey = key;
160
+ tunnels++;
161
+ routeNote(bot, ctx, pos);
162
+ await gotoGoal(bot, new goals.GoalNear(pos.x, pos.y, pos.z, 1), ctx).catch(() => undefined);
163
+ dropGoal(bot, 'task', '挪到位,接着挖', ctx.diag);
164
+ i--;
165
+ continue;
166
+ }
167
+ if (dug === 0 && sourceHint && err instanceof SkillBlocked) {
168
+ throw withRouteScene(bot, ctx, new SkillBlocked(`${err.message}。${sourceHint}`, err.scene), pos, [
169
+ `看得见的${zhName(block)}在 (${pos.x}, ${pos.y}, ${pos.z})`,
170
+ ]);
171
+ }
172
+ // 树冠这类"看得见够不着":带上目标坐标高差与三种走法的试算,她自己决定垫不垫
173
+ throw withRouteScene(bot, ctx, err, pos, [
174
+ `看得见的${zhName(block)}在 (${pos.x}, ${pos.y}, ${pos.z})`,
175
+ ]);
176
+ }
177
+ // 挖掘前撤掉寻路目标,避免 LookAt 随方块变化重算并调用 stopDigging。
178
+ dropGoal(bot, 'task', '挖掘期间寻路器歇手', ctx.diag);
179
+ const target = bot.blockAt(pos);
180
+ if (!target || !isTarget(target.name)) continue; // 移动期间目标方块可能被移除或掉落。
181
+ checkAbort(ctx);
182
+ await equipToolFor(bot, target, ctx, miningToolPlan(tool));
183
+ await digBlock(bot, target, ctx);
184
+ dug++;
185
+ ctx.progress?.(dug, count);
186
+ // 原版掉落物生成后 10 tick 才可拾取;等待实际入包,并设上限以容纳概率无掉落。
187
+ await gotoGoal(bot, new goals.GoalNear(pos.x, pos.y, pos.z, 1), ctx).catch(() => undefined);
188
+ const mark = countGains();
189
+ for (let waited = 0; waited < PICKUP_SETTLE_MS && countGains() === mark; waited += 100) {
190
+ checkAbort(ctx);
191
+ await sleep(100);
192
+ }
193
+ await keep.light(undefined, 'travel');
194
+ }
195
+ // 挖掘等级不够时它是"入包 0 个"唯一说得出口的解释,凡是回执都带上
196
+ const toolTail = toolFact ? `。${toolFact}` : '';
197
+ const gained = countGains() - before;
198
+ if (gained <= 0) {
199
+ // 挖掉方块与物品入包分别报告;collect 入包为零时不能算完成。
200
+ if (dug <= 0) {
201
+ throw new SkillBlocked(`一块${zhName(block)}都没挖到,入包 0 个`, toolFact ? [toolFact] : [], 'server');
202
+ }
203
+ // 概率掉落方块(drops 简表为空):挖成了没掉东西是正常结局,这一条不动
204
+ const blocksReg = bot.registry.blocks as unknown as Record<number, { drops?: unknown[] }>;
205
+ const certain = ids.some((id) => (blocksReg[id]?.drops ?? []).length > 0);
206
+ if (!certain) {
207
+ return `挖了 ${dug} 块${zhName(block)};这东西只按概率掉物品,这次一个都没掉${toolTail}${litTail()}`;
208
+ }
209
+ // 入包为零时检查是否包满,明确受阻来源。
210
+ const full = bot.inventory.items().length >= INVENTORY_SLOTS;
211
+ throw new SkillBlocked(
212
+ full
213
+ ? `挖掉了 ${dug} 块${zhName(block)}${dug < count ? `(要 ${count} 块)` : ''},` +
214
+ `但背包 ${INVENTORY_SLOTS} 格全满了,掉的东西进不来,都落在挖矿的地方了${toolTail}`
215
+ : `挖掉了 ${dug} 块${zhName(block)}${dug < count ? `(要 ${count} 块)` : ''},` +
216
+ `方块已经不在了,但一个都没进包:掉落物没捡到${toolTail}`,
217
+ toolFact ? [toolFact] : [], 'server',
218
+ );
219
+ }
220
+ // 部分完成必须显式报告,防止后续步骤误判所需库存已经到齐。
221
+ if (dug < count) {
222
+ const why = ranOutImmature > 0
223
+ ? `熟着的就这些,还有 ${ranOutImmature} 格没长成的留在地里`
224
+ : ranOut ? '近处再没有看得见的了' : '有几块走到跟前就不在了';
225
+ const spent = ranOut && ranOutImmature === 0 ? exhaustedMarkNote(bot, ctx, block) : '';
226
+ ctx.partial?.(`要 ${count} 块只挖到 ${dug} 块`);
227
+ return `挖了 ${dug} 块${zhName(block)}(要 ${count} 块),入包 ${gained} 个;${why}${spent}${toolTail}${litTail()}`;
228
+ }
229
+ return `挖了 ${dug} 块${zhName(block)},实际入包 ${gained} 个${toolTail}${litTail()}`;
230
+ }
231
+
232
+ /** 「这里有你登记的路标」按这个半径算:一处资源点的量级,不是一片地区 */
233
+ export const EXHAUSTED_MARK_RADIUS = 32;
234
+
235
+ /** 采空时并列报告附近已登记的路标,不自动修改路标 note。 */
236
+ export function exhaustedMarkNote(bot: Bot, ctx: SkillContext, block: string): string {
237
+ const here = ctx.marks?.()?.around(bot.entity.position, EXHAUSTED_MARK_RADIUS) ?? [];
238
+ if (here.length === 0) return '';
239
+ const names = here.map((m) => `「${m.name}」(记于 ${ctx.clock?.(m.at) ?? '不知道什么时候'})`).join('、');
240
+ return `;这一片的${zhName(block)}挖完了,这里有你登记的路标${names}`;
241
+ }
242
+
243
+ /** find 边走边找的扫描步长,小于感知半径,避免跨越目标。 */
244
+ export const EXPLORE_LEG = 24;
245
+
246
+ /** 站着扫一单最多报几处 */
247
+ export const FIND_HITS_MAX = 8;
248
+
249
+ /** 一般生成在建筑物里的方块:找不到时该说的是「进屋/隔窗才看得见」 */
250
+ export const INDOOR_TARGETS = new Set([
251
+ 'chest', 'trapped_chest', 'barrel', 'furnace', 'blast_furnace', 'smoker',
252
+ 'crafting_table', 'bookshelf', 'brewing_stand', 'lectern', 'smithing_table',
253
+ ]);
254
+
255
+ /** 找不到可见目标时,按目标类别和当前现场提供探索提示。 */
256
+ export function findEmptyHint(bot: Bot, ctx: SkillContext, target: string, isEntity: boolean): string {
257
+ if (!isEntity && INDOOR_TARGETS.has(target)) {
258
+ return `${zhName(target)}这类多半在屋里,隔着墙看不见——走到门口或者窗户跟前才照得见`;
259
+ }
260
+ if (!isEntity && /(_ore|ancient_debris)$/.test(target)) {
261
+ return `埋在石头里的${zhName(target)}看不见,得先挖开:tunnel 挖一条过去、或者顺着洞穴走,` +
262
+ '暴露在洞壁上的才找得到';
263
+ }
264
+ const risks = travelRisks(bot, ctx);
265
+ if (risks.length === 0) {
266
+ return isEntity
267
+ ? '活物会跑,换个方向走一段再找,或者等它们自己晃过来'
268
+ : '给个 direction 走一段再找,站着只看得到眼前这一圈';
269
+ }
270
+ return `${isEntity ? '这会儿附近没有' : '原地看不见'};要走过去找就加 direction ——` +
271
+ `但${risks.join('、')},这一趟要想清楚`;
272
+ }
273
+
274
+ /**
275
+ * 出这一趟门当下的三个读数:天光、手上有没有趁手的家伙、有没有重生点。
276
+ * 只报读数,一条都不构成拦阻(「出发前试算只拦事实,不拦权衡」)。
277
+ */
278
+ export function travelRisks(bot: Bot, ctx: SkillContext): string[] {
279
+ const out: string[] = [];
280
+ if (isNight(bot.time?.timeOfDay ?? 0)) out.push('现在是夜里');
281
+ if (!bestWeapon(bot)) out.push('你空着手(包里没有趁手的家伙)');
282
+ if (!(ctx.spawnAnchor?.() ?? null)) out.push('你现在没有重生点');
283
+ return out;
284
+ }
285
+
286
+ /** find 的一次扫描命中:在哪、叫什么、是不是活物 */
287
+ export interface ExploreHit { x: number; y: number; z: number; what: string; entity: boolean }
288
+
289
+ export function findKind(hit: ExploreHit | null, entityName: string | null): FindKind {
290
+ return hit?.entity || entityName !== null ? 'entity' : 'block';
291
+ }
292
+
293
+ /** 方块周围最近的可站落点,包含方块上方;没有则返回 null,方块占用格本身不可站。 */
294
+ export function approachCell(bot: Bot, c: Cell): Cell | null {
295
+ const me = bot.entity.position;
296
+ return [
297
+ { x: c.x, y: c.y + 1, z: c.z },
298
+ { x: c.x + 1, y: c.y, z: c.z }, { x: c.x - 1, y: c.y, z: c.z },
299
+ { x: c.x, y: c.y, z: c.z + 1 }, { x: c.x, y: c.y, z: c.z - 1 },
300
+ ]
301
+ .filter((s) => standableCell(bot, s))
302
+ .sort((a, b) => Math.hypot(a.x - me.x, a.y - me.y, a.z - me.z)
303
+ - Math.hypot(b.x - me.x, b.y - me.y, b.z - me.z))[0] ?? null;
304
+ }
305
+
306
+ export function findHitText(bot: Bot, hit: ExploreHit): string {
307
+ const point = `(${hit.x}, ${hit.y}, ${hit.z})`;
308
+ if (hit.entity) return `seenAt=${point}(它会动,这是看见那一刻的位置)`;
309
+ const stand = approachCell(bot, hit);
310
+ return `blockAt=${point}(目标方块占用格,不是可站落点` +
311
+ `${stand ? `;贴着它站得住的是 ${cellText(stand)},goto 走这一格` : ''})`;
312
+ }
313
+
314
+ export function findAgeText(ageMs: number): string {
315
+ const seconds = Math.floor(ageMs / 1000);
316
+ if (seconds < 60) return `${seconds} 秒前`;
317
+ const minutes = Math.floor(seconds / 60);
318
+ return minutes < 60 ? `${minutes} 分钟前` : `${Math.floor(minutes / 60)} 小时前`;
319
+ }
320
+
321
+ export function findHistoryNote(ctx: SkillContext, target: string, kind: FindKind): string {
322
+ const hit = ctx.search.history.recall(ctx.search.scope(), target, kind);
323
+ if (!hit) return '';
324
+ const point = `(${hit.at[0]}, ${hit.at[1]}, ${hit.at[2]})`;
325
+ const where = hit.kind === 'entity'
326
+ ? `seenAt=${point};活物此后可能已经移动`
327
+ : `blockAt=${point};这是目标方块占用格,不是可站落点`;
328
+ return `;真实历史观察:${findAgeText(hit.ageMs)}曾看见${hit.what},${where},现在没有复见,只能当过期线索`;
329
+ }
330
+
331
+ export function rememberFindHit(ctx: SkillContext, target: string, hit: ExploreHit): void {
332
+ ctx.search.history.remember(ctx.search.scope(), {
333
+ target,
334
+ kind: findKind(hit, null),
335
+ what: hit.what,
336
+ at: [hit.x, hit.y, hit.z],
337
+ });
338
+ }
339
+
340
+ /** target 是不是实体类型 id(sheep、cow、zombie);玩家不算,找玩家有 goto_player */
341
+ export function matchEntityName(bot: Bot, name: string): string | null {
342
+ const n = name.toLowerCase();
343
+ const entities = (bot.registry as unknown as { entitiesByName?: Record<string, unknown> }).entitiesByName;
344
+ return entities?.[n] ? n : null;
345
+ }
346
+
347
+ /**
348
+ * `#logs` 这种写法只在 `until` 名单里认。原文案「不认识「#logs」这种方块或实体」
349
+ * 让她把一个还能用的能力从工具箱里划掉了(笔记原文:「#ores 标签执行器不认」)。
350
+ * 不认识别的词时返回 null,由调用方说自己那句。
351
+ */
352
+ export function categoryScopeText(target: string): string | null {
353
+ if (!target.startsWith('#')) return null;
354
+ const key = target.slice(1);
355
+ const known = UNTIL_CATEGORIES[key] !== undefined;
356
+ const example = key === 'logs' ? 'oak_log' : key === 'ores' ? 'iron_ore' : null;
357
+ return `「${target}」是 until 名单的写法${known ? '' : `(名单只有 ${UNTIL_CATEGORY_DOC})`};`
358
+ + `target 这里写裸名 ${key.replace(/s$/, '')}${example ? ` 或具体 id ${example}` : ''}`;
359
+ }
360
+
361
+ /** 朝指定方向循环移动并扫描;调用方决定目标与方向。方块与实体走同一条感知规则。 */
362
+ export async function skillFind(
363
+ bot: Bot,
364
+ target: string,
365
+ direction: Direction | undefined,
366
+ distance: number,
367
+ ctx: SkillContext,
368
+ /** 行军途中的早停名单:路上碰到这里头任何一样就正常收束(站着扫用不上) */
369
+ until?: readonly string[],
370
+ ): Promise<string> {
371
+ const ids = matchBlockIds(bot, target);
372
+ const entityName = ids.length === 0 ? matchEntityName(bot, target) : null;
373
+ if (ids.length === 0 && entityName === null) {
374
+ throw new SkillBlocked(categoryScopeText(target) ?? `不认识「${target}」这种方块或实体`);
375
+ }
376
+ ctx.search.history.sync(ctx.search.scope());
377
+ const start = { x: bot.entity.position.x, z: bot.entity.position.z };
378
+ const finish = (text: string, hit: ExploreHit | null): string => {
379
+ if (hit) rememberFindHit(ctx, target, hit);
380
+ return text;
381
+ };
382
+
383
+ const scan = (): ExploreHit | null => {
384
+ // 与 collect/世界快照同一条感知规则:看得见才算找到
385
+ if (entityName) {
386
+ let best: ExploreHit | null = null;
387
+ let bestD = 48;
388
+ for (const key of Object.keys(bot.entities)) {
389
+ const e = bot.entities[key];
390
+ if (!e?.position || e === bot.entity) continue;
391
+ if ((e.name ?? '').toLowerCase() !== entityName) continue;
392
+ const d = e.position.distanceTo(bot.entity.position);
393
+ if (d < bestD && canSeeEntity(bot, e)) {
394
+ bestD = d;
395
+ const note = villagerNote(e as never);
396
+ best = {
397
+ x: Math.round(e.position.x), y: Math.round(e.position.y), z: Math.round(e.position.z),
398
+ what: `${zhEntity(entityName)}${note ? `(${note})` : ''}`, entity: true,
399
+ };
400
+ }
401
+ }
402
+ return best;
403
+ }
404
+ const found = bot.findBlocks({ matching: ids, maxDistance: 48, count: 16 });
405
+ const p = found.find((q) => canSeeBlockAt(bot, q));
406
+ // 回执使用扫描到的实际方块名:target 可能来自不可靠的视觉识别
407
+ return p ? { x: p.x, y: p.y, z: p.z, what: zhName(bot.blockAt(p)?.name ?? target), entity: false } : null;
408
+ };
409
+
410
+ /** 站着扫用:这一刻看得见的全部,按距离排。 */
411
+ const scanAll = (radius: number): ExploreHit[] => {
412
+ const me = bot.entity.position;
413
+ const out: ExploreHit[] = [];
414
+ if (entityName) {
415
+ for (const key of Object.keys(bot.entities)) {
416
+ const e = bot.entities[key];
417
+ if (!e?.position || e === bot.entity) continue;
418
+ if ((e.name ?? '').toLowerCase() !== entityName) continue;
419
+ if (e.position.distanceTo(bot.entity.position) > radius) continue;
420
+ if (!canSeeEntity(bot, e)) continue;
421
+ const note = villagerNote(e as never);
422
+ out.push({
423
+ x: Math.round(e.position.x), y: Math.round(e.position.y), z: Math.round(e.position.z),
424
+ what: `${zhEntity(entityName)}${note ? `(${note})` : ''}`, entity: true,
425
+ });
426
+ }
427
+ } else {
428
+ for (const q of bot.findBlocks({ matching: ids, maxDistance: radius, count: 64 })) {
429
+ if (!canSeeBlockAt(bot, q)) continue;
430
+ out.push({ x: q.x, y: q.y, z: q.z, what: zhName(bot.blockAt(q)?.name ?? target), entity: false });
431
+ }
432
+ }
433
+ const d2 = (h: ExploreHit): number => Math.hypot(h.x - me.x, h.y - me.y, h.z - me.z);
434
+ return out.sort((a, b) => d2(a) - d2(b));
435
+ };
436
+
437
+ // 站着扫:不给 direction 就是这一档。不挪地方、不进探索账本,只报这一刻看得见的。
438
+ if (direction === undefined) {
439
+ const radius = Math.min(distance, FIND_STATIC_MAX);
440
+ // 站着不走路,早停无从谈起 —— 收下了却没用上的参数必须自己说出来,
441
+ // 不然它就是一次静默吃掉(这条链上最贵的一类失败)
442
+ const idle = until && until.length > 0
443
+ ? `(写了 until,但这一趟是站着看一眼、人不动,碰不到东西也就没得停;要它管用得给 direction)`
444
+ : '';
445
+ const capped = (distance > FIND_STATIC_MAX
446
+ ? `(站着最远只看得到 ${FIND_STATIC_MAX} 格;要更远得给 direction 走过去)`
447
+ : '') + idle;
448
+ const hits = scanAll(radius);
449
+ if (hits.length === 0) {
450
+ const kind = findKind(null, entityName);
451
+ return finish(
452
+ `当前观察:在周围 ${radius} 格内没看见${zhThing(target)}${capped};` +
453
+ `这只说明当前已加载且视线可达的观察面没有命中,不表示目标不存在` +
454
+ `${findHistoryNote(ctx, target, kind)}。${findEmptyHint(bot, ctx, target, entityName !== null)}`,
455
+ null,
456
+ );
457
+ }
458
+ const me = bot.entity.position;
459
+ const feet = feetOf(bot);
460
+ const shown = hits.slice(0, FIND_HITS_MAX).map((h) => {
461
+ const dir = bearing(h.x - me.x, h.z - me.z);
462
+ const dy = h.y - feet.y;
463
+ const vertical = dy >= 3 ? '上方' : dy <= -3 ? '下方' : '';
464
+ const where = dir ? `${DIRECTION_ZH[dir]}边${vertical}` : (vertical || '脚边');
465
+ const away = Math.round(Math.hypot(h.x - me.x, h.y - me.y, h.z - me.z));
466
+ return `${h.what}${findHitText(bot, h)},我${where} ${away} 格`;
467
+ });
468
+ const more = hits.length - shown.length;
469
+ return finish(
470
+ `当前观察:在周围 ${radius} 格内看见 ${hits.length} 处${zhThing(target)}${capped}: ` +
471
+ `${shown.join('、')}${more > 0 ? `,另有 ${more} 处` : ''}`,
472
+ hits[0],
473
+ );
474
+ }
475
+
476
+ const walked = (): number => {
477
+ const p = bot.entity.position;
478
+ return Math.hypot(p.x - start.x, p.z - start.z);
479
+ };
480
+
481
+ /** 收工(走满/命中)落覆盖账本;原地命中(没走)不算探过 */
482
+ const settle = (): void => {
483
+ const d = Math.round(walked());
484
+ if (d > 0) ctx.explored?.(dimensionOf(bot), direction, d, biomeAt(bot, bot.entity.position));
485
+ };
486
+
487
+ const [dx, dz] = DIRECTIONS[direction];
488
+ const norm = Math.hypot(dx, dz);
489
+ /** 初始全向扫描命中时,回执使用命中点的实际方位与距离。 */
490
+ const hitHere = scan();
491
+ if (hitHere) {
492
+ const p = bot.entity.position;
493
+ const dir = bearing(hitHere.x - p.x, hitHere.z - p.z);
494
+ const away = Math.round(Math.hypot(hitHere.x - p.x, hitHere.z - p.z));
495
+ const where = dir ? `在我${DIRECTION_ZH[dir]}边 ${away} 格` : '就在脚边';
496
+ return finish(
497
+ `还没往${DIRECTION_ZH[direction]}走就看见了;请求的${DIRECTION_ZH[direction]}向行军尚未发生;` +
498
+ `这次命中来自出发点的初始全向观察,` +
499
+ `不是${DIRECTION_ZH[direction]}向搜索结果:${hitHere.what}${where},${findHitText(bot, hitHere)}`,
500
+ hitHere,
501
+ );
502
+ }
503
+
504
+ // 赶路途中的补光:lightWhen:"anywhere" 才做,默认这条不响
505
+ const keep = new Upkeep(bot, ctx);
506
+ const litTail = (): string => {
507
+ const t = keep.tally();
508
+ return t.length > 0 ? `;${t.join(';')}` : '';
509
+ };
510
+ // 早停名单:每走完一段扫一次。命中是**正常收束**,不是受阻——她要的就是"走到
511
+ // 碰见铁矿为止",走到那儿这一步就做完了
512
+ const stop = until && until.length > 0 ? untilBlockIds(bot, until) : null;
513
+ const stopNote = stop ? untilUnknownNote(stop.unknown) : '';
514
+ const stopHit = (): UntilHit | null =>
515
+ (stop ? untilHit(bot, stop.ids, UNTIL_TRAVEL_RADIUS, true) : null);
516
+ for (let travelled = 0; travelled < distance; travelled += EXPLORE_LEG) {
517
+ checkAbort(ctx);
518
+ await keep.light(undefined, 'travel');
519
+ const leg = Math.min(EXPLORE_LEG, distance - travelled);
520
+ const p = bot.entity.position;
521
+ const x = Math.round(p.x + (dx / norm) * leg);
522
+ const z = Math.round(p.z + (dz / norm) * leg);
523
+ const legGoal = levelTravelGoal(x, z);
524
+ try {
525
+ await gotoGoal(bot, legGoal, ctx);
526
+ } catch (err) {
527
+ if (err instanceof Aborted) throw err;
528
+ const hit = scan();
529
+ if (hit) {
530
+ settle();
531
+ const moved = Math.round(walked());
532
+ return finish(
533
+ `朝${DIRECTION_ZH[direction]}走了 ${moved} 格后走不动了,不过当前看见${hit.what},${findHitText(bot, hit)}`,
534
+ hit,
535
+ );
536
+ }
537
+ // 试算必须使用本段实际下达的 legGoal。
538
+ throw withRouteScene(bot, ctx, new SkillBlocked(
539
+ `朝${DIRECTION_ZH[direction]}走了 ${Math.round(walked())} 格就走不过去了(` +
540
+ `${(err as Error).message}),当前沿走过路线的可见面没看见${zhThing(target)},` +
541
+ `不表示目标不存在${findHistoryNote(ctx, target, findKind(null, entityName))}`,
542
+ ), { x, y: feetOf(bot).y, z }, [], legGoal);
543
+ }
544
+ const hit = scan();
545
+ if (hit) {
546
+ const p2 = bot.entity.position;
547
+ settle();
548
+ const moved = Math.round(walked());
549
+ return finish(
550
+ `朝${DIRECTION_ZH[direction]}走了 ${moved} 格,当前看见了${hit.what},${findHitText(bot, hit)};` +
551
+ `我现在在 (${Math.round(p2.x)}, ${Math.round(p2.y)}, ${Math.round(p2.z)})${litTail()}`,
552
+ hit,
553
+ );
554
+ }
555
+ const early = stopHit();
556
+ if (early) {
557
+ const p2 = bot.entity.position;
558
+ settle();
559
+ const moved = Math.round(walked());
560
+ return finish(
561
+ `朝${DIRECTION_ZH[direction]}走了 ${moved} 格,` +
562
+ `在 (${early.x}, ${early.y}, ${early.z}) 碰到了${early.what},停在这` +
563
+ `(当前观察还没看见${zhThing(target)},不表示目标不存在);` +
564
+ `我现在在 (${Math.round(p2.x)}, ${Math.round(p2.y)}, ${Math.round(p2.z)})${litTail()}${stopNote}`,
565
+ null,
566
+ );
567
+ }
568
+ }
569
+ const p = bot.entity.position;
570
+ settle();
571
+ // 视线扫描只能报告这条路线未见目标,不能排除整个方向;
572
+ // 室内目标可能被墙挡住,附按类别生成的探索提示。
573
+ const indoorTail = `。${findEmptyHint(bot, ctx, target, entityName !== null)}`;
574
+ // 净位移为零时须明确未离开出发点,不能声称已搜索完路线。
575
+ const w = Math.round(walked());
576
+ const how = w === 0
577
+ ? `朝${DIRECTION_ZH[direction]}这一趟没走出去(要走 ${distance} 格,人还在出发点),请求方向尚未实际搜索`
578
+ : `朝${DIRECTION_ZH[direction]}走满了 ${w} 格,沿走过路线的当前可见面一路没看见${zhThing(target)}`;
579
+ return finish(
580
+ `${how}${stop ? `,也没碰到 until 名单里的东西${stopNote}` : ''};` +
581
+ `这不表示目标不存在,也不能据此断言整个方向没有目标${findHistoryNote(ctx, target, findKind(null, entityName))};` +
582
+ `我现在在 (${Math.round(p.x)}, ${Math.round(p.y)}, ${Math.round(p.z)})${litTail()}${indoorTail}`,
583
+ null,
584
+ );
585
+ }
586
+
587
+ /** 一竿从抛出到咬钩的等待上限;原版浮标 5-30s 咬钩,到点按空军收竿 */
588
+ export const FISH_WAIT_MS = 45_000;
589
+ /** 浮标上方不见天时的等待上限;原版每 tick 有一半概率暂停倒计时,期望等待翻倍。 */
590
+ export const FISH_WAIT_COVERED_MS = 60_000;
591
+ /** 找水面的扫描半径:开阔水域按定义离岸 ≥3 格,8 格只够到岸沿 */
592
+ export const FISH_SCAN_R = 12;
593
+ /** 此直线距离内的水面免视线检查,允许岸壁上方向脚下水面投竿。 */
594
+ export const FISH_NEAR_R = 4;
595
+ /** 咬钩收线后战利品从浮标飞回入包的等待 */
596
+ export const FISH_LOOT_SETTLE_MS = 1_500;
597
+
598
+ /** 抛竿等待上限:浮标上方有遮盖就按原版的减半倒计时放宽 */
599
+ export function fishWaitMs(covered: boolean): number {
600
+ return covered ? FISH_WAIT_COVERED_MS : FISH_WAIT_MS;
601
+ }
602
+
603
+ /** 原版开阔水域判定里算「在水里」的方块:水源、水草(碰撞箱为空且必带水源) */
604
+ export const OPEN_WATER_INSIDE = new Set(['kelp', 'kelp_plant', 'seagrass', 'tall_seagrass', 'bubble_column']);
605
+
606
+ export type OpenWaterLayer = 'inside' | 'above' | 'invalid';
607
+
608
+ /**
609
+ * 原版 FishingHook.calculateOpenWater:以浮标格为中心 5×5,从下一层到上两层逐层看,
610
+ * 每层要么整层是水源(或水草),要么整层是空气/睡莲;最底那层必须是水,之后只允许
611
+ * 从水到空气转一次。等价于「周围 5×5 至少 2 格深、岸不在 2 格内」。宝藏只在这种
612
+ * 水里出;咬钩本身不看它。
613
+ */
614
+ export function isOpenFishingWater(bot: Bot, c: Cell): boolean {
615
+ const layerType = (y: number): OpenWaterLayer => {
616
+ let seen: OpenWaterLayer | null = null;
617
+ for (let dx = -2; dx <= 2; dx++) {
618
+ for (let dz = -2; dz <= 2; dz++) {
619
+ const b = bot.blockAt(new Vec3(c.x + dx, y, c.z + dz));
620
+ if (!b) return 'invalid';
621
+ let t: OpenWaterLayer;
622
+ if (AIR_NAMES.has(b.name) || b.name === 'lily_pad') t = 'above';
623
+ else if (b.name === 'water') t = blockProp(b, 'level') === '0' ? 'inside' : 'invalid';
624
+ else if (OPEN_WATER_INSIDE.has(b.name)) t = 'inside';
625
+ else t = 'invalid';
626
+ if (t === 'invalid') return 'invalid';
627
+ if (seen !== null && seen !== t) return 'invalid';
628
+ seen = t;
629
+ }
630
+ }
631
+ return seen ?? 'invalid';
632
+ };
633
+ let prev: OpenWaterLayer = 'invalid';
634
+ for (let dy = -1; dy <= 2; dy++) {
635
+ const t = layerType(c.y + dy);
636
+ if (t === 'invalid') return false;
637
+ if (t === 'above' && prev === 'invalid') return false;
638
+ if (t === 'inside' && prev === 'above') return false;
639
+ prev = t;
640
+ }
641
+ return true;
642
+ }
643
+
644
+ /** 选点结果:选中的水面格,以及它是不是开阔水域(有开阔水域就一定选它) */
645
+ export interface FishingSpot { cell: Cell; open: boolean }
646
+
647
+ /**
648
+ * 候选水面上方须非实心且非液体,优先开阔水域,再按距离选择。
649
+ * FISH_NEAR_R 内免视线检查;更远候选须可见或沿水面连接到可见/近处水格。
650
+ */
651
+ export function findFishingSpot(bot: Bot, maxDistance: number): FishingSpot | null {
652
+ const water = (bot.registry.blocksByName as Record<string, { id: number } | undefined>).water;
653
+ if (!water) throw new SkillBlocked('这个世界没有水这种方块');
654
+ const me = bot.entity.position;
655
+ // 只要水面格:深处的水格再多也不是落点。半径 12 的一片湖水面约 450 格,上限放宽到装得下
656
+ const surface = (b: { name: string; position: Vec3 }): boolean => {
657
+ const above = bot.blockAt(b.position.offset(0, 1, 0));
658
+ return !!above && above.boundingBox !== 'block' && !LIQUIDS.has(above.name);
659
+ };
660
+ const found = bot.findBlocks({ matching: [water.id], maxDistance, count: 512, useExtraInfo: surface })
661
+ .filter((p) => surface({ name: 'water', position: p }));
662
+ if (found.length === 0) return null;
663
+ const key = (p: { x: number; y: number; z: number }): string => `${p.x},${p.y},${p.z}`;
664
+ const byKey = new Map<string, Vec3>();
665
+ for (const p of found) byKey.set(key(p), p);
666
+ // 看得见的与近处的先入席,再沿水面 8 邻域(允许上下一格的落差)扩到同一片水
667
+ const seen = new Set<string>();
668
+ const queue: Vec3[] = [];
669
+ for (const p of found) {
670
+ if (p.distanceTo(me) <= FISH_NEAR_R || canSeeBlockAt(bot, p)) { seen.add(key(p)); queue.push(p); }
671
+ }
672
+ while (queue.length > 0) {
673
+ const p = queue.pop()!;
674
+ for (let dx = -1; dx <= 1; dx++) {
675
+ for (let dz = -1; dz <= 1; dz++) {
676
+ for (let dy = -1; dy <= 1; dy++) {
677
+ const k = `${p.x + dx},${p.y + dy},${p.z + dz}`;
678
+ if (seen.has(k)) continue;
679
+ const q = byKey.get(k);
680
+ if (!q) continue;
681
+ seen.add(k);
682
+ queue.push(q);
683
+ }
684
+ }
685
+ }
686
+ }
687
+ const visible = found.filter((p) => seen.has(key(p)));
688
+ if (visible.length === 0) return null;
689
+ const nearest = (list: Vec3[]): Vec3 => list.reduce((a, b) => (b.distanceTo(me) < a.distanceTo(me) ? b : a));
690
+ const open = visible.filter((p) => isOpenFishingWater(bot, { x: p.x, y: p.y, z: p.z }));
691
+ const pick = open.length > 0 ? nearest(open) : nearest(visible);
692
+ return { cell: { x: pick.x, y: pick.y, z: pick.z }, open: open.length > 0 };
693
+ }
694
+
695
+ /** 最近一格看得见的水面(上方不是实心也不是液体);着火找水复用,半径可放大 */
696
+ export function findFishingWater(bot: Bot, maxDistance = FISH_SCAN_R): Cell {
697
+ const spot = findFishingSpot(bot, maxDistance);
698
+ if (!spot) throw new SkillBlocked(`${maxDistance} 格内没看见能下竿的水面`);
699
+ return spot.cell;
700
+ }
701
+
702
+ /** 这片水没有开阔水域时回执里的那句事实 */
703
+ export const NO_OPEN_WATER_NOTE = '这片水没有开阔水域,只能钓岸边(不出宝藏)';
704
+
705
+ /**
706
+ * 浮标的服务端弹道:出手速度是 0.6 + 0.5/cos(仰角)(竖直分量取 tan(仰角),被钳在 ±5),
707
+ * 之后每 tick 先位移、再乘阻力 0.92、再吃重力 0.03。直线瞄准会系统性抛短——
708
+ * 瞄 3 格外落 2.7 格、瞄 8 格外落 5.9 格——而最近那格水就在岸沿,短一截就砸在岸上。
709
+ * 所以抛竿前按这套物理搜一个真能落进水里的仰角。
710
+ */
711
+ export const BOBBER_DRAG = 0.92;
712
+ export const BOBBER_GRAVITY = 0.03;
713
+ /** 竖直分量 tan(仰角) 被服务端钳在 ±5(≈78.7°),再陡也不会更陡 */
714
+ export const BOBBER_TAN_CLAMP = 5;
715
+ /** 浮标出手点在眼睛处、沿水平朝向前 0.3 格 */
716
+ export const BOBBER_SPAWN_FWD = 0.3;
717
+ /** 一竿最多模拟这么多 tick,以及浮标离人多远就被服务端收走 */
718
+ export const BOBBER_MAX_TICKS = 120;
719
+ export const BOBBER_MAX_DIST = 32;
720
+ /** 搜仰角的范围与步长(度);正为抬头 */
721
+ export const AIM_MIN_DEG = -80;
722
+ export const AIM_MAX_DEG = 45;
723
+ export const AIM_STEP_DEG = 1;
724
+
725
+ export type BobberHit =
726
+ | { hit: 'water'; x: number; y: number; z: number; dist: number }
727
+ | { hit: 'solid' | 'unloaded' | 'lost' };
728
+
729
+ /** 落点搜索用的方块视图:只要名字和挡不挡路 */
730
+ export type BlockPeek = (x: number, y: number, z: number) => { name: string; solid: boolean } | null;
731
+
732
+ /**
733
+ * 从 eye 沿水平朝向 (hx, hz)、以 elev 弧度的仰角抛一竿,模拟到落点。
734
+ * 逐 tick 走位移并细分采样:先碰到水算落水,先碰到实心算落地。
735
+ */
736
+ export function simulateBobber(
737
+ eye: { x: number; y: number; z: number },
738
+ hx: number,
739
+ hz: number,
740
+ elev: number,
741
+ peek: BlockPeek,
742
+ ): BobberHit {
743
+ const tan = Math.max(-BOBBER_TAN_CLAMP, Math.min(BOBBER_TAN_CLAMP, Math.tan(elev)));
744
+ const d3 = Math.hypot(1, tan);
745
+ const m = 0.6 / d3 + 0.5;
746
+ let vx = hx * m;
747
+ let vy = tan * m;
748
+ let vz = hz * m;
749
+ let px = eye.x + hx * BOBBER_SPAWN_FWD;
750
+ let py = eye.y;
751
+ let pz = eye.z + hz * BOBBER_SPAWN_FWD;
752
+ for (let t = 0; t < BOBBER_MAX_TICKS; t++) {
753
+ const n = Math.max(1, Math.ceil(Math.hypot(vx, vy, vz) / 0.25));
754
+ for (let s = 1; s <= n; s++) {
755
+ const qx = px + (vx * s) / n;
756
+ const qy = py + (vy * s) / n;
757
+ const qz = pz + (vz * s) / n;
758
+ const b = peek(Math.floor(qx), Math.floor(qy), Math.floor(qz));
759
+ if (!b) return { hit: 'unloaded' };
760
+ if (b.name === 'water') {
761
+ return { hit: 'water', x: qx, y: qy, z: qz, dist: Math.hypot(qx - eye.x, qz - eye.z) };
762
+ }
763
+ if (b.solid) return { hit: 'solid' };
764
+ }
765
+ px += vx; py += vy; pz += vz;
766
+ vx *= BOBBER_DRAG;
767
+ vz *= BOBBER_DRAG;
768
+ vy = vy * BOBBER_DRAG - BOBBER_GRAVITY;
769
+ if (Math.hypot(px - eye.x, py - eye.y, pz - eye.z) > BOBBER_MAX_DIST) return { hit: 'lost' };
770
+ }
771
+ return { hit: 'lost' };
772
+ }
773
+
774
+ /** 一个候选抛法:仰角(弧度)与它预测的落水点离目标格中心多远 */
775
+ export type AimPlan = { elev: number; dist: number; miss: number };
776
+
777
+ /**
778
+ * 搜出所有能落进水里的仰角,按落点离目标格多近排序。
779
+ * 空表示这个站位怎么抛都进不了水(该换个站位或换个目标)。
780
+ */
781
+ export function planFishingCasts(bot: Bot, target: Cell): AimPlan[] {
782
+ const me = bot.entity.position;
783
+ const eye = { x: me.x, y: me.y + ((bot.entity as { eyeHeight?: number }).eyeHeight ?? 1.62), z: me.z };
784
+ const tx = target.x + 0.5;
785
+ const ty = target.y + 0.9;
786
+ const tz = target.z + 0.5;
787
+ const dx = tx - eye.x;
788
+ const dz = tz - eye.z;
789
+ const h = Math.hypot(dx, dz);
790
+ // 正下方的水没有水平朝向可言,给个任意朝向,让近乎垂直的抛法照样能搜出来
791
+ const hx = h < 1e-6 ? 1 : dx / h;
792
+ const hz = h < 1e-6 ? 0 : dz / h;
793
+ // 一次搜要问上千次方块,同一格只查一遍
794
+ const memo = new Map<string, { name: string; solid: boolean } | null>();
795
+ const peek: BlockPeek = (x, y, z) => {
796
+ const key = `${x},${y},${z}`;
797
+ const hit = memo.get(key);
798
+ if (hit !== undefined) return hit;
799
+ const b = bot.blockAt(new Vec3(x, y, z));
800
+ const v = b ? { name: b.name, solid: b.boundingBox === 'block' } : null;
801
+ memo.set(key, v);
802
+ return v;
803
+ };
804
+ const plans: AimPlan[] = [];
805
+ for (let deg = AIM_MIN_DEG; deg <= AIM_MAX_DEG; deg += AIM_STEP_DEG) {
806
+ const elev = (deg * Math.PI) / 180;
807
+ const r = simulateBobber(eye, hx, hz, elev, peek);
808
+ if (r.hit !== 'water') continue;
809
+ plans.push({ elev, dist: r.dist, miss: Math.hypot(r.x - tx, r.y - ty, r.z - tz) });
810
+ }
811
+ plans.sort((a, b) => a.miss - b.miss);
812
+ return plans;
813
+ }
814
+
815
+ /** 我这一竿的浮标:附近唯一一个 fishing_bobber 实体 */
816
+ export function findBobber(bot: Bot): NonNullable<Bot['entities'][string]> | null {
817
+ const me = bot.entity.position;
818
+ let best: NonNullable<Bot['entities'][string]> | null = null;
819
+ let bestD = Infinity;
820
+ for (const id of Object.keys(bot.entities)) {
821
+ const e = bot.entities[id];
822
+ if (!e?.position || e.name !== 'fishing_bobber') continue;
823
+ const d = e.position.distanceTo(me);
824
+ if (d <= BOBBER_MAX_DIST + 4 && d < bestD) { bestD = d; best = e; }
825
+ }
826
+ return best;
827
+ }
828
+
829
+ /** 浮标此刻所在的那一格 */
830
+ export function bobberCell(bot: Bot): { name: string; x: number; y: number; z: number } | null {
831
+ const e = findBobber(bot);
832
+ if (!e) return null;
833
+ const p = e.position;
834
+ const x = Math.floor(p.x);
835
+ const y = Math.floor(p.y);
836
+ const z = Math.floor(p.z);
837
+ const b = bot.blockAt(new Vec3(x, y, z));
838
+ return { name: b?.name ?? 'unknown', x, y, z };
839
+ }
840
+
841
+ /** 抛出后等浮标停下来的上限;飞行一般 10~30 tick */
842
+ export const BOBBER_SETTLE_MS = 2_000;
843
+ /** 落点不是水就收竿换仰角重抛,一次 fish 最多抛这么多竿 */
844
+ export const FISH_CAST_TRIES = 3;
845
+
846
+ /**
847
+ * 等浮标停稳并回报它停在哪一格。落进水里立刻回;落在地上要连着几次读数不动才算停。
848
+ * 始终看不见浮标实体(实体没同步过来)回 null——那种情况不拦着,照旧等咬钩。
849
+ */
850
+ export async function settleBobber(bot: Bot, ctx: SkillContext): Promise<{ name: string; x: number; y: number; z: number } | null> {
851
+ const deadline = Date.now() + BOBBER_SETTLE_MS;
852
+ let last: string | null = null;
853
+ let stable = 0;
854
+ while (Date.now() < deadline) {
855
+ checkAbort(ctx);
856
+ await sleep(100);
857
+ const at = bobberCell(bot);
858
+ if (!at) { last = null; stable = 0; continue; }
859
+ if (at.name === 'water') return at;
860
+ const key = `${at.x},${at.y},${at.z}`;
861
+ if (key === last) {
862
+ stable++;
863
+ if (stable >= 3) return at;
864
+ } else {
865
+ last = key;
866
+ stable = 0;
867
+ }
868
+ }
869
+ return bobberCell(bot);
870
+ }
871
+
872
+ /**
873
+ * 钓一竿。`bot.fish()` 抛竿等浮标粒子,咬钩时它自己收线并 resolve;它没有超时,
874
+ * 也没有取消入口——中止/超时靠再挥一次竿收回浮标,浮标销毁后 fish() 以
875
+ * "Fishing cancelled" 收场。收获按物品栏差分照实报。
876
+ *
877
+ * `bot.fish()` 只等咬钩粒子,不看浮标落在哪:落岸上就白等满 45 秒再谎报「没鱼咬钩」。
878
+ * 所以仰角自己按弹道搜(见 planFishingCasts),抛完先看浮标真停在哪一格,
879
+ * 不是水就立刻收竿换下一个仰角重抛。
880
+ */
881
+ export async function skillFish(bot: Bot, call: Extract<SkillCall, { skill: 'fish' }>, ctx: SkillContext): Promise<string> {
882
+ checkAbort(ctx);
883
+ let water: Cell;
884
+ /** 目标格不是开阔水域时跟在回执后面的事实;是开阔水域就没有这一句 */
885
+ let openNote = '';
886
+ if (call.at) {
887
+ water = resolveAt(bot, call.at);
888
+ const b = blockAtCell(bot, water);
889
+ if (!b) throw new SkillBlocked(`${cellText(water)} 那里区块没加载`);
890
+ if (b.name !== 'water') throw new SkillBlocked(`${cellText(water)} 不是水,是${zhName(b.name)}`);
891
+ if (!isOpenFishingWater(bot, water)) {
892
+ openNote = `;${cellText(water)} 不是开阔水域(周围 5×5 不够 2 格深或岸在 2 格内),只出鱼不出宝藏`;
893
+ }
894
+ } else {
895
+ const spot = findFishingSpot(bot, FISH_SCAN_R);
896
+ if (!spot) throw new SkillBlocked(`${FISH_SCAN_R} 格内没看见能下竿的水面`);
897
+ water = spot.cell;
898
+ if (!spot.open) openNote = `;${NO_OPEN_WATER_NOTE}`;
899
+ }
900
+ const rod = bot.inventory.items().find((i) => i.name === 'fishing_rod');
901
+ if (!rod) throw new SkillBlocked('包里没有钓竿');
902
+ await bot.equip(rod, 'hand');
903
+
904
+ // 原地有可行抛竿轨迹时不移动;否则走近后重新求抛物线,避免直接走入水面格。
905
+ let plans = planFishingCasts(bot, water);
906
+ if (plans.length === 0) {
907
+ const me = bot.entity.position;
908
+ if (Math.hypot(me.x - water.x, me.y - water.y, me.z - water.z) > 3.5) {
909
+ try {
910
+ await gotoGoal(bot, new goals.GoalNear(water.x, water.y, water.z, 3), ctx);
911
+ } catch (err) {
912
+ throw withRouteScene(bot, ctx, err, water);
913
+ }
914
+ plans = planFishingCasts(bot, water);
915
+ }
916
+ }
917
+ if (plans.length === 0) {
918
+ throw new SkillBlocked(`站在这抛不进 ${cellText(water)} 的水里(挡着或够不着),换个站位或换一片开阔水面`);
919
+ }
920
+ const here = bot.entity.position;
921
+ const yaw = Math.atan2(-(water.x + 0.5 - here.x), -(water.z + 0.5 - here.z));
922
+
923
+ const before = invSnapshot(bot);
924
+ let outcome = '';
925
+ let strayCell: { name: string; x: number; y: number; z: number } | null = null;
926
+ /** 浮标头顶到世界顶之间有实心遮盖:原版倒计时减半,等待上限随之放宽 */
927
+ let covered = false;
928
+ let waitMs = FISH_WAIT_MS;
929
+ let casts = 0;
930
+ for (const plan of plans.slice(0, FISH_CAST_TRIES)) {
931
+ checkAbort(ctx);
932
+ casts++;
933
+ await bot.look(yaw, plan.elev, true);
934
+ const cast = bot.fish().then(() => 'caught' as const, (e: Error) => `失败:${e.message}`);
935
+ // 先看浮标真落在哪:落岸上就收竿换下一个仰角,不白等 45 秒
936
+ const landed = await Promise.race([cast.then(() => null), settleBobber(bot, ctx)]);
937
+ if (landed && landed.name !== 'water') {
938
+ strayCell = landed;
939
+ bot.activateItem(); // 收竿销毁浮标,挂着的 fish() 以 Fishing cancelled 收场
940
+ // 浮标销毁包没来的话 fish() 会一直挂着,不等它,让下一竿的 fish() 去取消它
941
+ await Promise.race([cast.catch(() => undefined), sleep(500)]);
942
+ continue;
943
+ }
944
+ strayCell = null;
945
+ // 浮标实体没同步过来时读不到它头顶,按露天等
946
+ covered = landed !== null && skyBlocked(bot, landed.x, landed.y + 1, landed.z);
947
+ waitMs = fishWaitMs(covered);
948
+ const deadline = Date.now() + waitMs;
949
+ for (;;) {
950
+ const r = await Promise.race([cast, sleep(250).then(() => null)]);
951
+ if (r !== null) { outcome = r; break; }
952
+ if (ctx.aborted() || Date.now() >= deadline) {
953
+ bot.activateItem(); // 收竿;浮标销毁让还挂着的 fish() 取消掉
954
+ if (ctx.aborted()) throw new Aborted(ctx.abortedBy?.() ?? null);
955
+ outcome = 'timeout';
956
+ break;
957
+ }
958
+ }
959
+ break;
960
+ }
961
+ if (strayCell) {
962
+ throw new SkillBlocked(
963
+ `抛了 ${casts} 竿,浮标都落在 (${strayCell.x}, ${strayCell.y}, ${strayCell.z}) 的${zhName(strayCell.name)}上、没进水里;` +
964
+ '换个站位或指一片更开阔的水面',
965
+ );
966
+ }
967
+ const notes = `${covered ? ';这里浮标头顶看不到天,咬钩慢' : ''}${openNote}`;
968
+ if (outcome === 'caught') {
969
+ await sleep(FISH_LOOT_SETTLE_MS);
970
+ const gains = invGains(before, bot);
971
+ if (gains.length > 0) return `钓上来${gains.join('、')}${notes}`;
972
+ // 战利品从浮标飞回;背包无空位时可能落在脚边。
973
+ const used = bot.inventory.items().length;
974
+ return used >= PLAYER_SLOTS
975
+ ? `咬钩了也收了线,包满了(${PLAYER_SLOTS} 格全占着),战利品掉在脚边${notes}`
976
+ : `咬钩了也收了线,东西没进包(包里还有 ${PLAYER_SLOTS - used} 格空位)${notes}`;
977
+ }
978
+ if (outcome === 'timeout') {
979
+ throw new SkillBlocked(`在 ${cellText(water)} 抛竿等了 ${Math.round(waitMs / 1000)} 秒没鱼咬钩,收竿了${notes}`);
980
+ }
981
+ throw new SkillBlocked(`这竿没钓成: ${zhErrorText(outcome.replace(/^失败:/, ''))}`);
982
+ }
983
+
984
+ /** trade 的实体查找半径与 attack 一致 */
985
+ export const TRADE_FIND_R = 32;
986
+ /** 开交易窗的等待上限;没职业的村民右键无响应 */
987
+ export const TRADE_OPEN_MS = 5_000;
988
+ // 报价包在各协议世代下的三个名字(mineflayer 按 supportFeature 三选一注册监听)
989
+ export const TRADE_LIST_PACKETS = ['trade_list', 'minecraft:trader_list', 'MC|TrList'];
990
+ export type PacketListener = (...args: unknown[]) => void;
991
+ export interface PacketEmitter {
992
+ listeners(name: string): PacketListener[];
993
+ removeListener(name: string, fn: PacketListener): void;
994
+ }
995
+
996
+ /** mineflayer 的 openVillager 返回值里这里用得到的面 */
997
+ export interface VillagerWindow {
998
+ trades: Array<{
999
+ inputItem1: { name: string; count: number };
1000
+ inputItem2?: { name: string; count: number } | null;
1001
+ hasItem2: boolean;
1002
+ outputItem: { name: string; count: number };
1003
+ realPrice?: number;
1004
+ tradeDisabled: boolean;
1005
+ nbTradeUses: number;
1006
+ maximumNbTradeUses: number;
1007
+ }> | null;
1008
+ trade(index: number, count: number): Promise<void>;
1009
+ }
1010
+
1011
+ /** 报价一行:「1号:24小麦→1绿宝石」;卖断货标锁死 */
1012
+ export function tradeLine(t: NonNullable<VillagerWindow['trades']>[number], i: number): string {
1013
+ const ins = [`${t.realPrice ?? t.inputItem1.count}${zhName(t.inputItem1.name)}`];
1014
+ if (t.hasItem2 && t.inputItem2) ins.push(`${t.inputItem2.count}${zhName(t.inputItem2.name)}`);
1015
+ const locked = t.tradeDisabled || t.maximumNbTradeUses - t.nbTradeUses <= 0;
1016
+ return `${i + 1}号:${ins.join('+')}→${t.outputItem.count}${zhName(t.outputItem.name)}${locked ? '(锁死)' : ''}`;
1017
+ }
1018
+
1019
+ /**
1020
+ * 村民交易两段式:无 index 只报菜单不成交,带 index 按单成交、回执报实收实付。
1021
+ * 入口在 `use target:"villager"` —— 交易本来就是"右键这个活物",不该另立技能名。
1022
+ * openVillager 断言实体是 villager;流浪商人共用同一套商人窗口与报价包,
1023
+ * 借道时临时对上它认的 entityType,开完窗即还原。
1024
+ */
1025
+ export async function skillTrade(
1026
+ bot: Bot,
1027
+ call: { target: string; index?: number; times?: number },
1028
+ ctx: SkillContext,
1029
+ ): Promise<string> {
1030
+ checkAbort(ctx);
1031
+ const me = bot.entity.position;
1032
+ let entity: NonNullable<Bot['entities'][string]> | null = null;
1033
+ let bestD = Infinity;
1034
+ for (const id of Object.keys(bot.entities)) {
1035
+ const e = bot.entities[id];
1036
+ if (!e?.position || e.name !== call.target) continue;
1037
+ if (!canSeeEntity(bot, e)) continue;
1038
+ const d = e.position.distanceTo(me);
1039
+ if (d <= TRADE_FIND_R && d < bestD) { bestD = d; entity = e; }
1040
+ }
1041
+ if (!entity) throw new SkillBlocked(`附近 ${TRADE_FIND_R} 格内没看见${zhEntity(call.target)}`);
1042
+ if (bestD > 3.5) await gotoGoal(bot, new goals.GoalFollow(entity, 2), ctx).catch(() => undefined);
1043
+ checkAbort(ctx);
1044
+ if (entity.position.distanceTo(bot.entity.position) > 4.5) {
1045
+ throw new SkillBlocked(`走不到${zhEntity(call.target)}身边(它在 ${Math.round(entity.position.distanceTo(bot.entity.position))} 格外)`);
1046
+ }
1047
+
1048
+ const before = invSnapshot(bot);
1049
+ const registry = bot.registry as unknown as { entitiesByName?: Record<string, { id: number } | undefined> };
1050
+ const villagerId = registry.entitiesByName?.villager?.id;
1051
+ const eAny = entity as unknown as { entityType?: number };
1052
+ const origType = eAny.entityType;
1053
+ // 村民身份以实体元数据为准,回执同时说明所点实体及其身份。
1054
+ const note = villagerNote(entity as never);
1055
+ const who = `${zhEntity(call.target)}${note ? `(${note})` : ''}`;
1056
+ // openVillager 在等窗之前就往 _client 挂了个 async 的报价包监听器,只在窗口 close 时摘。
1057
+ // 窗没开出来这条路上它留在原地,手里攥着一个必然 reject 的 promise;下一次真收到报价包时
1058
+ // 它就地抛出,而 EventEmitter 不接返回值 —— 无人认领的 rejection 会掀掉整个引擎子进程。
1059
+ const client = (bot as unknown as { _client: PacketEmitter })._client;
1060
+ const kept = new Map(TRADE_LIST_PACKETS.map((name) => [name, new Set(client.listeners(name))]));
1061
+ let win: VillagerWindow | null;
1062
+ try {
1063
+ if (villagerId !== undefined) eAny.entityType = villagerId;
1064
+ const opening = (bot as unknown as { openVillager(e: unknown): Promise<VillagerWindow> })
1065
+ .openVillager(entity);
1066
+ opening.catch(() => undefined);
1067
+ win = await Promise.race([opening, sleep(TRADE_OPEN_MS).then(() => null)]);
1068
+ } finally {
1069
+ eAny.entityType = origType;
1070
+ }
1071
+ if (!win) {
1072
+ for (const name of TRADE_LIST_PACKETS) {
1073
+ for (const fn of client.listeners(name)) {
1074
+ if (!kept.get(name)!.has(fn)) client.removeListener(name, fn);
1075
+ }
1076
+ }
1077
+ // 开不出窗按身份分开说:无业/傻子/小孩是原版规则,照实说;有职业的开不出来
1078
+ // 不编理由,报事实并点名右键的是哪一只,免得她把锅扣到镜头里另一只村民头上
1079
+ const why = note === '还没有职业' || note === '傻子' ? `${note}的村民做不了买卖`
1080
+ : note === '小孩' ? '小孩做不了买卖'
1081
+ : note ? `它是${note},按理有报价,这次没等到,原因这份回执说不清`
1082
+ : '它的职业没读出来,原因也说不清';
1083
+ throw new SkillBlocked(`右键了${who}(在 ${cellText({ x: Math.round(entity.position.x), y: Math.round(entity.position.y), z: Math.round(entity.position.z) })}),等了 ${TRADE_OPEN_MS / 1000} 秒交易窗没开出来:${why}`);
1084
+ }
1085
+
1086
+ const close = (): void => { bot.closeWindow(win as never); };
1087
+ const trades = win.trades;
1088
+ if (!trades || trades.length === 0) {
1089
+ close();
1090
+ throw new SkillBlocked(`${who}一条报价都没有`);
1091
+ }
1092
+ if (call.index === undefined) {
1093
+ close();
1094
+ return `${who}的报价:${trades.map(tradeLine).join(';')}。没成交,要买带 "index" 再来一单`;
1095
+ }
1096
+ const idx = call.index - 1;
1097
+ if (idx < 0 || idx >= trades.length) {
1098
+ close();
1099
+ throw new SkillBlocked(`报价只有 ${trades.length} 条,没有 ${call.index} 号`);
1100
+ }
1101
+ const t = trades[idx];
1102
+ const left = t.maximumNbTradeUses - t.nbTradeUses;
1103
+ if (t.tradeDisabled || left <= 0) {
1104
+ close();
1105
+ throw new SkillBlocked(`${call.index} 号报价(${tradeLine(t, idx)})锁死了,卖断货,换一条或等它补货`);
1106
+ }
1107
+ const times = Math.min(call.times ?? 1, left);
1108
+ try {
1109
+ await win.trade(idx, times);
1110
+ } catch (err) {
1111
+ close();
1112
+ const msg = (err as Error).message;
1113
+ if (/not enough item/i.test(msg)) {
1114
+ throw new SkillBlocked(`付不起 ${call.index} 号(${tradeLine(t, idx)}):包里的货不够`);
1115
+ }
1116
+ if (/trade blocked/i.test(msg)) {
1117
+ throw new SkillBlocked(`${call.index} 号报价锁死了,卖断货`);
1118
+ }
1119
+ throw new SkillBlocked(`没成交: ${zhErrorText(msg)}`);
1120
+ }
1121
+ close();
1122
+ // 开窗期间 bot.inventory 是旧账,关窗灌回后差分才作数
1123
+ await sleep(150);
1124
+ const gains: string[] = [];
1125
+ const paid: string[] = [];
1126
+ const after = invSnapshot(bot);
1127
+ const names = new Set([...before.keys(), ...after.keys()]);
1128
+ for (const name of names) {
1129
+ const d = (after.get(name) ?? 0) - (before.get(name) ?? 0);
1130
+ if (d > 0) gains.push(`${zhName(name)}×${d}`);
1131
+ else if (d < 0) paid.push(`${zhName(name)}×${-d}`);
1132
+ }
1133
+ const clamp = times < (call.times ?? 1) ? `(额度只剩 ${left} 次,按 ${times} 次成交)` : '';
1134
+ return `按 ${call.index} 号成交 ${times} 次${clamp}:付出${paid.length > 0 ? paid.join('、') : '?(账上没见少)'},` +
1135
+ `进账${gains.length > 0 ? gains.join('、') : '?(账上没见多)'}`;
1136
+ }
1137
+
1138
+ /* ========== 锚点几何技能族:probe / build / excavate / tunnel ========== */
1139
+
1140
+ /** 那一格是作物就带上原版 age 原值;不是作物给空串(与快照的括注同一形式) */
1141
+ export function probeAgeText(bot: Bot, c: Cell): string {
1142
+ const age = cropAgeOfCell(bot, c);
1143
+ return age ? `(age ${age.value}/${age.max})` : '';
1144
+ }
1145
+
1146
+ /** probe.where 直接检查指定区域的区块数据,不经过视线闸;零匹配也明确回报。 */
1147
+ export function probeWhereText(
1148
+ bot: Bot,
1149
+ call: Extract<SkillCall, { skill: 'probe' }>,
1150
+ where: readonly string[],
1151
+ pre: ReadonlyArray<{ c: Cell; name: string | null }>,
1152
+ unloaded: number,
1153
+ ): string {
1154
+ const want = untilBlockIds(bot, where);
1155
+ const names = blockNamesOf(bot, want.ids);
1156
+ const me = bot.entity.position;
1157
+ const found = new Map<string, Cell[]>();
1158
+ for (const e of pre) {
1159
+ if (e.name === null || !names.has(e.name)) continue;
1160
+ const list = found.get(e.name) ?? [];
1161
+ list.push(e.c);
1162
+ found.set(e.name, list);
1163
+ }
1164
+ const lines = [...found]
1165
+ .map(([name, at]) => {
1166
+ at.sort((a, b) => Math.hypot(a.x - me.x, a.y - me.y, a.z - me.z)
1167
+ - Math.hypot(b.x - me.x, b.y - me.y, b.z - me.z));
1168
+ const shown = at.slice(0, PROBE_WHERE_SHOWN).map(cellText).join('、');
1169
+ return `${zhName(name)}×${at.length}${at.length > PROBE_WHERE_SHOWN ? `,最近的 ${shown}` : `:${shown}`}`;
1170
+ })
1171
+ .sort();
1172
+ const head = `探查${SHAPE_ZH[call.shape]}(共 ${pre.length} 格)里点名的那几样`;
1173
+ const body = lines.length > 0 ? `: ${lines.join(';')}` : ':一样都没有';
1174
+ const miss = names.size === 0 ? '(点名的这几样一个都认不出来)' : '';
1175
+ const tail = unloaded > 0 ? `。${unloaded} 格区块没加载,那几格没读到` : '';
1176
+ return `${head}${miss}${body}${untilUnknownNote(want.unknown)}${tail}。这一档直接读区块,不受遮挡与视线限制`;
1177
+ }
1178
+
1179
+ /**
1180
+ * 探查:只读不动。≤27 格逐格列坐标,大体积报聚合构成;"target" 只报命中格。
1181
+ * 同参重复探查且读数没变时只回「与上次相同」——差分记在执行器上,跨任务有效,重启清。
1182
+ */
1183
+ export async function skillProbe(bot: Bot, call: Extract<SkillCall, { skill: 'probe' }>, ctx: SkillContext): Promise<string> {
1184
+ checkAbort(ctx);
1185
+ const locating = call.where !== undefined && call.where.length > 0;
1186
+ const cells = shapeCells(
1187
+ bot, call.shape, call.anchors, call.fill, locating ? PROBE_WHERE_CELL_CAP : PROBE_CELL_CAP,
1188
+ );
1189
+ const pre = cells.map((c) => {
1190
+ const b = blockAtCell(bot, c);
1191
+ return { c, name: b?.name ?? null };
1192
+ });
1193
+ const unloaded = pre.filter((e) => e.name === null).length;
1194
+ if (call.where !== undefined && locating) return probeWhereText(bot, call, call.where, pre, unloaded);
1195
+
1196
+ const memo = ctx.probeMemo;
1197
+ const geoKey = fnv32(`${call.shape}|${cells.map((c) => `${c.x},${c.y},${c.z}`).join(';')}`);
1198
+ // 作物的 age 进指纹:名字没变、龄期跳档也是新读数,不然「熟了没」永远回「与上次相同」
1199
+ const readHash = fnv32(pre.map((e) => {
1200
+ if (e.name === null) return '?';
1201
+ return e.name in CROP_MAX_AGE ? `${e.name}@${cropAgeOfCell(bot, e.c)?.value ?? '?'}` : e.name;
1202
+ }).join(','));
1203
+ if (memo?.last && memo.last.key === geoKey && memo.last.hash === readHash) {
1204
+ memo.last.count += 1;
1205
+ return `与上次探查相同(第 ${memo.last.count} 次)。上次: ${memo.last.summary}`;
1206
+ }
1207
+
1208
+ const head = `探查${SHAPE_ZH[call.shape]}(共 ${cells.length} 格)`;
1209
+ const lines: string[] = [];
1210
+ if (cells.length <= PROBE_CELLWISE_MAX) {
1211
+ const listed = pre.filter((e) => e.name !== null && !AIR_NAMES.has(e.name));
1212
+ const airCells = pre.filter((e) => e.name !== null && AIR_NAMES.has(e.name)).map((e) => e.c);
1213
+ if (listed.length === 0) {
1214
+ lines.push(`${head}: 全是空气。`);
1215
+ } else {
1216
+ const airTail = airCells.length > 0 ? `;其余 ${airCells.length} 格是空气` : '';
1217
+ lines.push(`${head},逐格: ${listed.map((e) => `(${e.c.x},${e.c.y},${e.c.z}):${zhName(e.name!)}${probeAgeText(bot, e.c)}`).join('、')}${airTail}。`);
1218
+ }
1219
+ pushPocketLine(bot, lines, cells, airCells);
1220
+ } else {
1221
+ const reading = readRegion(bot, cells);
1222
+ lines.push(`${head}: ${compositionText(reading)}。`);
1223
+ pushPocketLine(bot, lines, cells, reading.air);
1224
+ }
1225
+ if (unloaded > 0) lines.push(`${unloaded} 格区块没加载,没读到。`);
1226
+ const text = lines.join('');
1227
+ if (memo) memo.last = { key: geoKey, hash: readHash, count: 1, summary: lines[0] };
1228
+ return text;
1229
+ }
1230
+