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,1038 @@
1
+ /**
2
+ * 把东西放下去:贴哪一面、站哪儿放得着、放完回读确认,以及随手补光与耗材许可。
3
+ *
4
+ * 只管一格一格地放与站位,放什么、放成什么形状由技能族决定。
5
+ */
6
+ import type { Bot } from 'mineflayer';
7
+ import pathfinderPkg from 'mineflayer-pathfinder';
8
+ import { Vec3 } from 'vec3';
9
+ import { chestBlockName, matchItemName, matchMaterialName } from './chests.ts';
10
+ import { blockStateItem } from './blueprint-registry.ts';
11
+ import { BLOCK_FACES, cellOnFace, resolveAnchors, type BlockFace, type Cell } from './geometry.ts';
12
+ import { isDark, isNight, pocketScan, sampleLight } from './terrain.ts';
13
+ import {
14
+ BUILD_CELL_CAP, FACE_TRY_ORDER, LIQUIDS, NO_PLACE_REFERENCE, PLACE_REACH, blockAtCell, cellKeyOf,
15
+ cellText, chebyshev, dimensionOf, feetOf, refCellOf, shapeCells, skyBlocked, solidAt,
16
+ } from './cell-facts.ts';
17
+ import {
18
+ Aborted, SkillBlocked, checkAbort, sleep, type BlueprintSite, type ResourcePlacementPermit,
19
+ type SkillContext,
20
+ } from './skill-context.ts';
21
+ import { zhName } from './names.ts';
22
+ import { gotoGoal } from './travel.ts';
23
+ import { invCount, type InvItem } from './inventory.ts';
24
+ import { forgetPlaced } from './placed-ledger.ts';
25
+ import { contentsText, type PlaceCall, zhErrorText } from './receipt.ts';
26
+ import { FALLBACK_DEFAULTS, isGravityBlock } from './policy.ts';
27
+ import { type PositionXYZ } from './blueprint.ts';
28
+
29
+ const { goals } = pathfinderPkg;
30
+
31
+ /**
32
+ * 放置验收按「目标方块由这份材料放出」匹配。
33
+ * 材料选择仍走精确 ID;落地方块再经 registry 映回物品,收住 torch→wall_torch 等原版形态转换。
34
+ */
35
+ export function matchPlacedMaterialName(bot: Bot, material: string, blockName: string): boolean {
36
+ if (matchMaterialName(bot.registry, material, blockName)) return true;
37
+ if (!blockName) return false;
38
+ const placedBy = blockStateItem(`minecraft:${blockName}`).item;
39
+ return placedBy !== null && matchMaterialName(bot.registry, material, placedBy);
40
+ }
41
+
42
+ /** 封闭空腔报告数值;只有非露天探查才报告与更大空间连通。 */
43
+ export function pushPocketLine(bot: Bot, lines: string[], cells: Cell[], airCells: Cell[]): void {
44
+ if (airCells.length === 0) return;
45
+ const read = (x: number, y: number, z: number) => {
46
+ const b = bot.blockAt(new Vec3(x, y, z));
47
+ return b ? { name: b.name, solid: b.boundingBox === 'block' } : null;
48
+ };
49
+ const pocket = pocketScan(read, airCells);
50
+ if (pocket !== null) {
51
+ lines.push(`这片空气是封死的,连它外面一共约 ${pocket} 格。`);
52
+ return;
53
+ }
54
+ const cx = Math.round(cells.reduce((s, c) => s + c.x, 0) / cells.length);
55
+ const cy = Math.round(cells.reduce((s, c) => s + c.y, 0) / cells.length);
56
+ const cz = Math.round(cells.reduce((s, c) => s + c.z, 0) / cells.length);
57
+ if (skyBlocked(bot, cx, cy + 1, cz)) {
58
+ lines.push('这片空气连着更大的空间(灌了 128 格还没摸到边)。');
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 跳起来把方块垫到自己脚下。成功 = 脚下那一格变成了要放的东西。
64
+ *
65
+ * 判据不能是"那一格实心了":火把、树苗这些没有碰撞箱,按实心判会把放成功的一律当失败
66
+ * (`placeIntoCell` 早就改过,这里漏了)。给了 material 就按名字比对,不给(垫脚上行)
67
+ * 仍按实心判——那条路要的就是站得上去。
68
+ */
69
+ export async function jumpPlaceBelow(bot: Bot, ctx: SkillContext, material?: string): Promise<boolean> {
70
+ const feet = bot.entity.position.floored();
71
+ const below = bot.blockAt(feet.offset(0, -1, 0));
72
+ if (!below || below.boundingBox !== 'block') return false;
73
+ for (let attempt = 0; attempt < 3; attempt++) {
74
+ checkAbort(ctx);
75
+ await bot.lookAt(feet.offset(0.5, 0, 0.5), true);
76
+ bot.setControlState('jump', true);
77
+ const airborne = Date.now() + 1_200;
78
+ while (bot.entity.position.y - feet.y < 0.95 && Date.now() < airborne) await sleep(50);
79
+ try {
80
+ await bot.placeBlock(below, new Vec3(0, 1, 0));
81
+ } catch {
82
+ /* 时机不对就再跳一次 */
83
+ }
84
+ bot.setControlState('jump', false);
85
+ await sleep(400);
86
+ const now = bot.blockAt(feet);
87
+ if (!now) continue;
88
+ if (material ? matchPlacedMaterialName(bot, material, now.name) : now.boundingBox === 'block') return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /** 垫脚失败时报告脚下格实际内容,不选择清理或绕行方案。 */
94
+ export function padFailure(bot: Bot): string {
95
+ const feet = feetOf(bot);
96
+ const here = bot.blockAt(new Vec3(feet.x, feet.y, feet.z));
97
+ const below = bot.blockAt(new Vec3(feet.x, feet.y - 1, feet.z));
98
+ // 人在哪由调用方报一次;这里只说那一格的实情
99
+ const at = '垫不上脚下那一格';
100
+ if (!below || below.boundingBox !== 'block') {
101
+ return `${at}:下面 ${cellText({ x: feet.x, y: feet.y - 1, z: feet.z })} 是` +
102
+ `${below ? zhName(below.name) : '没加载的区块'},没有能贴着放的实心面`;
103
+ }
104
+ if (here && here.name !== 'air') return `${at}:那一格现在是${zhName(here.name)}`;
105
+ return `${at}:那一格是空气,连放 3 次服务端都没认`;
106
+ }
107
+
108
+ /** 贴这一面放时的参照方块;得是实心方块且不在原版确定性拒绝的名单里,否则 null */
109
+ export function usableReference(bot: Bot, cell: Cell, face: BlockFace): ReturnType<Bot['blockAt']> {
110
+ const ref = blockAtCell(bot, refCellOf(cell, face));
111
+ if (!ref || ref.boundingBox !== 'block' || NO_PLACE_REFERENCE.has(ref.name)) return null;
112
+ return ref;
113
+ }
114
+
115
+ /**
116
+ * 六个面里第一个能当参照的;一个都没有时 null。
117
+ *
118
+ * 归因用:`placeIntoCell` 的 null 混着两件事 —— 一包都没发出去(没有参照面)与
119
+ * 发了三包被服务端拒。两者她要换的招不一样,回执不许都写成「服务端不认」。
120
+ */
121
+ export function placeReferenceFace(bot: Bot, cell: Cell, face?: BlockFace): BlockFace | null {
122
+ return (face ? [face] : FACE_TRY_ORDER).find((f) => usableReference(bot, cell, f) !== null) ?? null;
123
+ }
124
+
125
+ /**
126
+ * 把一块材料放进指定格,返回贴的是哪一面(放不上返回 null)。
127
+ *
128
+ * 放置在原版里就是(参照方块,面)这一对:给了 `face` 就只点那一面,她说了贴哪儿
129
+ * 就不必猜;没给就按 `FACE_TRY_ORDER` 挨个试,回执照实报最后贴上的是哪一面。
130
+ * 成没成看的是"那一格变成了要放的东西",不是"那一格实心了" ——
131
+ * 火把、树苗、种子这些没有碰撞箱,按实心判会把放成功的一律当失败。
132
+ */
133
+ export async function placeIntoCell(
134
+ bot: Bot, cell: Cell, material: string, ctx: SkillContext, face?: BlockFace,
135
+ ): Promise<BlockFace | null> {
136
+ const became = (): boolean => {
137
+ const b = blockAtCell(bot, cell);
138
+ return b != null && matchPlacedMaterialName(bot, material, b.name);
139
+ };
140
+ // 潜行着放:mineflayer 的 _genericPlace 从不告诉服务端"我按着 shift"(它源码里那行
141
+ // `// TODO: tell the server that we are sneaking while doing this` 还在)。不潜行时
142
+ // 右键交互方块,服务端执行的是打开它而不是放方块——参照是门/箱子/工作台/熔炉时,
143
+ // 六个面全试完也放不上一块。
144
+ bot.setControlState('sneak', true);
145
+ try {
146
+ for (const f of face ? [face] : FACE_TRY_ORDER) {
147
+ checkAbort(ctx);
148
+ // 原版拒绝以耕地或作物作为此放置的参照面,跳过无效面。
149
+ const ref = usableReference(bot, cell, f);
150
+ if (!ref) continue;
151
+ const [dx, dy, dz] = BLOCK_FACES[f];
152
+ try {
153
+ await bot.placeBlock(ref, new Vec3(dx, dy, dz));
154
+ } catch {
155
+ continue;
156
+ }
157
+ await sleep(150);
158
+ if (became()) return f;
159
+ }
160
+ } finally {
161
+ bot.setControlState('sneak', false);
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * 放置前重新确认手持物,寻路挖掘可能已经换成工具;缺料返回 false。
168
+ * 材料名按 build 同一规则匹配完整名或材料后缀。
169
+ */
170
+ export async function ensureHolding(bot: Bot, material: string): Promise<boolean> {
171
+ const fits = (n: string): boolean => matchMaterialName(bot.registry, material, n);
172
+ if (bot.heldItem && fits(bot.heldItem.name)) return true;
173
+ const item = bot.inventory.items().find((i) => fits(i.name));
174
+ if (!item) return false;
175
+ await bot.equip(item, 'hand');
176
+ return true;
177
+ }
178
+
179
+ /** 扫掉落物的范围:脚边这一坑,不是半个区块 */
180
+ export const SWEEP_RANGE = 6;
181
+ export const SWEEP_DY = 3;
182
+
183
+ /**
184
+ * 挖完在原地扫一遍掉落物:东西落在刚挖开的那个坑里,站在坑外挖的人不会自己捡。
185
+ *
186
+ * 范围必须卡死。放宽到 12 格时寻路会为了一件飘到高处的掉落物垫柱子爬上去,
187
+ * 再把刚挖空的格子填回来——捡回的还不够填回去的。
188
+ */
189
+ export async function sweepDrops(bot: Bot, ctx: SkillContext): Promise<void> {
190
+ for (let i = 0; i < 6; i++) {
191
+ checkAbort(ctx);
192
+ const me = bot.entity.position;
193
+ let nearest: { pos: { x: number; y: number; z: number }; d: number } | null = null;
194
+ for (const id of Object.keys(bot.entities)) {
195
+ const e = bot.entities[id];
196
+ if (!e?.position || (e.name !== 'item' && e.name !== 'item_stack')) continue;
197
+ if (Math.abs(e.position.y - me.y) > SWEEP_DY) continue;
198
+ const d = Math.hypot(e.position.x - me.x, e.position.z - me.z);
199
+ if (d > 1.2 && d <= SWEEP_RANGE && (!nearest || d < nearest.d)) nearest = { pos: e.position, d };
200
+ }
201
+ if (!nearest) return;
202
+ await gotoGoal(bot, new goals.GoalNear(nearest.pos.x, nearest.pos.y, nearest.pos.z, 0.5), ctx)
203
+ .catch(() => undefined);
204
+ await sleep(250);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 维持条件:干活途中世界不对劲时,就地补一个有界的小动作,然后接着做原来那件事。
210
+ *
211
+ * 与反射层的分界是"要不要放下手上这件事":反射(岩浆、低血、溺水)打断并接管,
212
+ * 走 `preempt()`;维持不打断,补一格、插一根,回到原来那一步。
213
+ *
214
+ * 三条,每条由四件事定义 —— 什么时候触发、补什么、料从哪来、没料就不做。
215
+ * 料一律从常驻规矩(`mc_policy`)取,做过的进收工回执报数,没做成的单独可报。
216
+ */
217
+ export type UpkeepKind = 'footing' | 'climb' | 'light';
218
+
219
+ /** 这一处补光属于哪个场合;`travel` 那一类由 `mc_policy` 的 `lightWhen` 决定做不做 */
220
+ export type LightOccasion = 'dig' | 'travel';
221
+
222
+ /** 一条条件的料:名单从设置来,包里有没有当场看。没料就不做,不是失败 */
223
+ export type Stock = { item: InvItem } | { why: string };
224
+
225
+ export type PermittedStock =
226
+ | { item: InvItem; permit: Extract<ResourcePlacementPermit, { ok: true }> }
227
+ | { why: string };
228
+
229
+ export function permitPlacement(ctx: SkillContext, item: string, preview = false): ResourcePlacementPermit {
230
+ // 试算不动世界:走只读判据,不取 permit —— 取了会在放置结算期把"上一块材料还在
231
+ // 结算,这次放置稍后再试"塞进预览文案,而预览本来就不放任何东西
232
+ if (preview) {
233
+ const decision = ctx.previewResourcePlacement?.(item) ?? { ok: true };
234
+ return decision.ok ? { ok: true, finish: () => {} } : { ok: false, reason: decision.reason ?? '' };
235
+ }
236
+ return ctx.permitResourcePlacement?.(item) ?? { ok: true, finish: () => {} };
237
+ }
238
+
239
+ /** 按策略优先级选择有库存且获放置许可的第一种材料。 */
240
+ export function permittedStockFor(
241
+ bot: Bot,
242
+ names: string[] | null,
243
+ label: string,
244
+ ctx: SkillContext,
245
+ preview = false,
246
+ ): PermittedStock {
247
+ if (!names) return { why: `${label}名单是空的(设置里关了)` };
248
+ const items = bot.inventory.items();
249
+ let denied = '';
250
+ for (const name of names) {
251
+ const item = items.find((candidate) => matchItemName(name, candidate.name));
252
+ if (!item) continue;
253
+ const permit = permitPlacement(ctx, item.name, preview);
254
+ if (permit.ok) return { item, permit };
255
+ denied ||= permit.reason;
256
+ }
257
+ return denied
258
+ ? { why: denied }
259
+ : { why: `${label}名单里的方块包里都没有(${names.map((n) => zhName(n)).join('、')})` };
260
+ }
261
+
262
+ /**
263
+ * 插下去到服务端把新光照推回来之间有一拍,这段时间那一片读到的还是黑。
264
+ * 离上一根火把这么近就不再判黑:原版火把光源 14、每格衰减 1,6 格外仍有 8。
265
+ */
266
+ export const TORCH_SPACING = 6;
267
+ /** 亮度众数要按面连通漫 96 格,挖一块查一次不值;两秒一次跟得上走路的速度 */
268
+ export const LIGHT_CHECK_MS = 2_000;
269
+ /**
270
+ * 火把先贴四面墙,贴不住才落到地上。
271
+ * 竖井里脚下那一格就是下一铲,插在地上一挖就跟着掉;墙上那根整条井都留得住。
272
+ */
273
+ export const TORCH_FACES: readonly BlockFace[] = ['north', 'south', 'west', 'east', 'up'];
274
+
275
+ /** 一次技能执行期间的维持条件账:做成几次、上一次没做成是为什么 */
276
+ export class Upkeep {
277
+ private padded = 0;
278
+ private lit = 0;
279
+ private litName = 'torch';
280
+ private lastTorch: Cell | null = null;
281
+ /**
282
+ * 上次补光失败的格;与只记录成功的 lastTorch 分开去重。
283
+ * 脚下格改变后失效,不使用时间冷却。
284
+ */
285
+ private lastLightMiss: Cell | null = null;
286
+ private lastLightCheck = 0;
287
+ private readonly missed = new Map<UpkeepKind, string>();
288
+
289
+ constructor(private readonly bot: Bot, private readonly ctx: SkillContext) {}
290
+
291
+ /** 脚下没底 → 把那一格垫上。垫成了才 true;没料/服务端不认/落在工地里都进 `why` */
292
+ async footing(cell: Cell): Promise<boolean> {
293
+ // 身体占据的放置格会被服务端拒绝,须在发包前检查 occupiedByMe。
294
+ if (occupiedByMe(this.bot, cell)) {
295
+ this.missed.set('footing', `${cellText(cell)} 是我自己站着的那一格,人在里面放不进方块`);
296
+ return false;
297
+ }
298
+ const site = siteAtCell(this.ctx, cell);
299
+ if (site) {
300
+ this.missed.set('footing', siteRefusalText(site, cell));
301
+ return false;
302
+ }
303
+ // 只擦进去几厘米也拒。退半步是这一格唯一的自救,退不开就点名,不发那一包
304
+ if (hitboxBlocks(this.bot, cell) && !(await stepOffCell(this.bot, this.ctx, cell))) {
305
+ this.missed.set('footing', `${cellText(cell)} 被我自己的身子压着一角,退不开半步,放不进方块`);
306
+ return false;
307
+ }
308
+ const stock = permittedStockFor(this.bot, scaffoldNames(this.ctx), '垫脚', this.ctx);
309
+ if ('why' in stock) {
310
+ this.missed.set('footing', stock.why);
311
+ return false;
312
+ }
313
+ let placed = false;
314
+ try {
315
+ await this.bot.equip(stock.item, 'hand');
316
+ placed = (await placeIntoCell(this.bot, cell, stock.item.name, this.ctx)) !== null;
317
+ } finally {
318
+ stock.permit.finish(placed);
319
+ }
320
+ if (!placed) {
321
+ this.missed.set('footing', placeReferenceFace(this.bot, cell) === null
322
+ ? `${cellText(cell)} 六个面都没有能贴着放的实心方块,这一包没发出去`
323
+ : `拿${zhName(stock.item.name)}放了,服务端不认`);
324
+ return false;
325
+ }
326
+ this.padded++;
327
+ return true;
328
+ }
329
+
330
+ /** 上不去 → 跳起来把方块垫到自己脚下。占位的是谁由 `padFailure` 点名 */
331
+ async climb(): Promise<boolean> {
332
+ // 垫的是脚这一格(jumpPlaceBelow 跳起来往 feet 里放),判的也是它
333
+ const feet = feetOf(this.bot);
334
+ const site = siteAtCell(this.ctx, feet);
335
+ if (site) {
336
+ this.missed.set('climb', siteRefusalText(site, feet));
337
+ return false;
338
+ }
339
+ const stock = permittedStockFor(this.bot, scaffoldNames(this.ctx), '垫脚', this.ctx);
340
+ if ('why' in stock) {
341
+ this.missed.set('climb', stock.why);
342
+ return false;
343
+ }
344
+ let placed = false;
345
+ try {
346
+ await this.bot.equip(stock.item, 'hand');
347
+ placed = await jumpPlaceBelow(this.bot, this.ctx, stock.item.name);
348
+ } finally {
349
+ stock.permit.finish(placed);
350
+ }
351
+ if (!placed) {
352
+ this.missed.set('climb', padFailure(this.bot));
353
+ return false;
354
+ }
355
+ return true;
356
+ }
357
+
358
+ /**
359
+ * 连通区域光照众数 ≤1 时补光,与快照的黑暗判据一致。
360
+ * avoid 标出接下来要挖的格,不能用作火把支撑面。
361
+ * dig 场合补光;travel 场合仅在 lightWhen 为 anywhere 时补光。
362
+ */
363
+ async light(avoid?: (c: Cell) => boolean, occasion: LightOccasion = 'dig'): Promise<void> {
364
+ if (this.ctx.noLight) return;
365
+ if (occasion === 'travel' && (this.ctx.policy?.get().lightWhen ?? 'dig') !== 'anywhere') return;
366
+ const names = lightNames(this.ctx);
367
+ if (!names) return;
368
+ const now = Date.now();
369
+ if (now - this.lastLightCheck < LIGHT_CHECK_MS) return;
370
+ this.lastLightCheck = now;
371
+ const feet = feetOf(this.bot);
372
+ if (this.lastTorch && chebyshev(feet, this.lastTorch) < TORCH_SPACING) return;
373
+ if (this.lastLightMiss && cellKeyOf(this.lastLightMiss) === cellKeyOf(feet)) return;
374
+ if (!isDark(sampleLight(this.bot, isNight(this.bot.time?.timeOfDay ?? 0)))) return;
375
+ const occupied = blockAtCell(this.bot, feet);
376
+ if (occupied && names.some((name) => matchPlacedMaterialName(this.bot, name, occupied.name))) {
377
+ this.lastTorch = feet;
378
+ return;
379
+ }
380
+ // 与 build 共用 boundingBox 为 block 的占位判据;耕地和塌落沙砾可能落在脚下取整格内。
381
+ if (occupied && occupied.boundingBox === 'block') {
382
+ this.lastLightMiss = feet;
383
+ this.missed.set('light', `我站的这一格 ${occupantText(this.bot, feet)},火把插不进去`);
384
+ return;
385
+ }
386
+ const stock = permittedStockFor(this.bot, names, '照明', this.ctx);
387
+ if ('why' in stock) {
388
+ this.missed.set('light', stock.why);
389
+ return;
390
+ }
391
+ let on: BlockFace | null = null;
392
+ try {
393
+ await this.bot.equip(stock.item, 'hand');
394
+ for (const f of TORCH_FACES) {
395
+ if (avoid?.(refCellOf(feet, f))) continue;
396
+ on = await placeIntoCell(this.bot, feet, stock.item.name, this.ctx, f);
397
+ if (on) break;
398
+ }
399
+ } finally {
400
+ stock.permit.finish(on !== null);
401
+ }
402
+ if (!on) {
403
+ this.lastLightMiss = feet;
404
+ this.missed.set('light', `拿${zhName(stock.item.name)}放了,四面墙和脚下都没贴住`);
405
+ return;
406
+ }
407
+ // 有意插下去的那一根由这里点名报数,不能再被贴成「路上垫脚/搭路用掉了」
408
+ forgetPlaced(this.bot, feet);
409
+ this.lit++;
410
+ this.litName = stock.item.name;
411
+ this.lastTorch = feet;
412
+ this.lastLightMiss = null;
413
+ }
414
+
415
+ /** 上一次没做成是为什么;这一趟没试过就是空串 */
416
+ why(kind: UpkeepKind): string {
417
+ return this.missed.get(kind) ?? '';
418
+ }
419
+
420
+ /**
421
+ * 做过的事进收工回执。`climb` 不在这里 —— 它的次数就是调用方自己的进度数
422
+ * (塔的格数、爬到露天上来的格数),再报一遍就是同一件事说两遍。
423
+ */
424
+ tally(): string[] {
425
+ const out: string[] = [];
426
+ if (this.padded > 0) out.push(`路上有 ${this.padded} 格没底,垫上了`);
427
+ if (this.lit > 0) out.push(`顺手插了 ${this.lit} 根${zhName(this.litName)}`);
428
+ const darkWhy = this.missed.get('light');
429
+ if (darkWhy) out.push(`有一段黑着没插上:${darkWhy}`);
430
+ return out;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * 一格放不上时,那一格里现在是什么。
436
+ *
437
+ * 六面都试过还是没放上,原因几乎总在这一格自己身上:火把、墙上火把、树苗、作物这些
438
+ * 没有碰撞箱、`classify` 当空格,可服务端顶不掉,放几次都白搭。回执必须点名占位的是谁——
439
+ * 只说"剩 1 格放不上"读不出下一步该拆什么。
440
+ */
441
+ export function occupantOf(bot: Bot, c: Cell): string {
442
+ const b = blockAtCell(bot, c);
443
+ if (!b) return '区块没加载';
444
+ if (b.name === 'air') return '是空气,服务端就是不认';
445
+ return `现在是${zhName(b.name)}`;
446
+ }
447
+
448
+ export function occupantText(bot: Bot, c: Cell): string {
449
+ return `${cellText(c)} ${occupantOf(bot, c)}`;
450
+ }
451
+
452
+ /**
453
+ * 多格家具的占位:床横着占两格、门与高草竖着占两格。
454
+ *
455
+ * 不维护表——从 minecraft-data 的方块状态推:`part:head/foot` = 横向两格(16 种床),
456
+ * `half:upper/lower` = 纵向两格(30 种门/高草/大蕨/向日葵…)。
457
+ * 楼梯活板门那 76 种的 `half` 是 `top/bottom`,只占半格,两个值集合交集为 0,不会误判。
458
+ */
459
+ export type Footprint = 'single' | 'horizontal' | 'vertical';
460
+
461
+ export function footprintOf(bot: Bot, material: string): Footprint {
462
+ const def = (bot.registry.blocksByName as Record<string, { states?: Array<{ name: string; values?: string[] }> } | undefined>)[material];
463
+ for (const s of def?.states ?? []) {
464
+ if (s.name === 'part' && s.values?.includes('head')) return 'horizontal';
465
+ if (s.name === 'half' && s.values?.includes('upper')) return 'vertical';
466
+ }
467
+ return 'single';
468
+ }
469
+
470
+ /** 一格能不能让东西占进去:空着(或能被顶掉),且脚下踩得住 */
471
+ export function footFree(bot: Bot, c: Cell): boolean {
472
+ const b = blockAtCell(bot, c);
473
+ return b != null && b.boundingBox !== 'block';
474
+ }
475
+
476
+ /**
477
+ * 多格家具放不下时的现场:附近哪些位置的占位是**够的**。
478
+ * 她从快照里看不到"哪儿有连续两格空位",只能靠一次次试——这是照实回报,不是代她决定。
479
+ */
480
+ export function footprintScene(bot: Bot, center: Cell, material: string, fp: Footprint): string[] {
481
+ const R = 8;
482
+ const label = zhName(material);
483
+ const fits: Array<{ text: string; d: number }> = [];
484
+ const me = bot.entity?.position ?? { x: center.x, y: center.y, z: center.z };
485
+ const pairs: Array<[number, number, number]> = fp === 'vertical'
486
+ ? [[0, 1, 0]]
487
+ : [[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
488
+ for (let dx = -R; dx <= R; dx++) {
489
+ for (let dy = -2; dy <= 2; dy++) {
490
+ for (let dz = -R; dz <= R; dz++) {
491
+ const a = { x: center.x + dx, y: center.y + dy, z: center.z + dz };
492
+ if (!footFree(bot, a)) continue;
493
+ if (!solidAt(bot, { x: a.x, y: a.y - 1, z: a.z })) continue;
494
+ for (const [ox, oy, oz] of pairs) {
495
+ const b = { x: a.x + ox, y: a.y + oy, z: a.z + oz };
496
+ if (!footFree(bot, b)) continue;
497
+ if (fp === 'horizontal' && !solidAt(bot, { x: b.x, y: b.y - 1, z: b.z })) continue;
498
+ fits.push({
499
+ text: `${cellText(a)}+${cellText(b)}`,
500
+ d: Math.hypot(a.x + 0.5 - me.x, a.y - me.y, a.z + 0.5 - me.z),
501
+ });
502
+ break;
503
+ }
504
+ }
505
+ }
506
+ }
507
+ const how = fp === 'vertical' ? '上下连续两格空、脚下实心' : '横着连续两格空、脚下都实心';
508
+ if (fits.length === 0) return [`${R} 格内没有${how}的位置,放不下${label}`];
509
+ // 候选只按距离排序,不作位置优劣推荐。
510
+ fits.sort((p, q) => p.d - q.d);
511
+ const shown = fits.slice(0, FOOTPRINT_SPOTS_MAX);
512
+ return [
513
+ `${label}要占两格。${R} 格内${how}的位置有 ${fits.length} 处:${shown.map((f) => f.text).join('、')}`
514
+ + (fits.length > shown.length ? `(共 ${fits.length} 处,按远近取前 ${shown.length})` : ''),
515
+ ];
516
+ }
517
+
518
+ /** 候选位置一次列几处;排序判据是离她多远,越靠前越近 */
519
+ export const FOOTPRINT_SPOTS_MAX = 5;
520
+
521
+ /** 一处要放的地方:落点那一格,以及她指名的贴面(没指名 = null,由执行器挑) */
522
+ export interface BuildSpot { cell: Cell; face: BlockFace | null }
523
+
524
+ /**
525
+ * build 的两种入参落到同一批落点上:贴面形态给的是(参照方块,面),落点由这一对算出来;
526
+ * 格子清单/形状形态给的是落点本身,贴哪一面由执行器挑。
527
+ */
528
+ export function buildSpots(bot: Bot, call: PlaceCall): BuildSpot[] {
529
+ const spots: BuildSpot[] = [];
530
+ if ('on' in call) {
531
+ const refs = resolveAnchors(call.on.map((o) => o.at), feetOf(bot));
532
+ if (!Array.isArray(refs)) throw new SkillBlocked(refs.error);
533
+ for (const [i, ref] of refs.entries()) {
534
+ spots.push({ cell: cellOnFace(ref, call.on[i].face), face: call.on[i].face });
535
+ }
536
+ } else {
537
+ for (const cell of shapeCells(bot, call.shape, call.anchors, call.fill, BUILD_CELL_CAP)) {
538
+ spots.push({ cell, face: null });
539
+ }
540
+ }
541
+ const seen = new Set<string>();
542
+ return spots.filter((s) => {
543
+ const k = cellKeyOf(s.cell);
544
+ if (seen.has(k)) return false;
545
+ seen.add(k);
546
+ return true;
547
+ });
548
+ }
549
+
550
+ /**
551
+ * 走到"放得上这一格"的位置。落脚点不能选在要填的那一格里:GoalNear(c,2) 常把人送到
552
+ * 目标格上站着,服务端随即以"那儿有个玩家"拒掉这一次放置。GoalPlaceBlock 的 `isStandingIn`
553
+ * 把脚与脑袋两格都算作站在里面,并按视线求交挑落脚点,与服务端那一次射线判定同口径。
554
+ */
555
+ export async function gotoPlaceable(bot: Bot, s: BuildSpot, ctx: SkillContext): Promise<boolean> {
556
+ // GoalPlaceBlock 的 faces 是"参照方块相对落点的方位",与面向量正好反向;
557
+ // 不指名面时由它自己兜六个面。facing 也一律兜默认(不限朝向)
558
+ const opts: Record<string, unknown> = { range: PLACE_REACH, LOS: true };
559
+ if (s.face) {
560
+ const [dx, dy, dz] = BLOCK_FACES[s.face];
561
+ opts.faces = [new Vec3(-dx, -dy, -dz)];
562
+ }
563
+ try {
564
+ await gotoGoal(
565
+ bot,
566
+ new goals.GoalPlaceBlock(
567
+ new Vec3(s.cell.x, s.cell.y, s.cell.z), bot.world,
568
+ opts as unknown as ConstructorParameters<typeof goals.GoalPlaceBlock>[2],
569
+ ),
570
+ ctx,
571
+ );
572
+ return true;
573
+ } catch (err) {
574
+ if (err instanceof Aborted) throw err;
575
+ return false;
576
+ }
577
+ }
578
+
579
+ /** 那一格现在是我自己占着的(脚或脑袋) */
580
+ export function occupiedByMe(bot: Bot, c: Cell): boolean {
581
+ const f = feetOf(bot);
582
+ return f.x === c.x && f.z === c.z && (f.y === c.y || f.y + 1 === c.y);
583
+ }
584
+
585
+ /** 玩家碰撞箱:0.6 见方、1.8 高,以脚下坐标为底面中心 */
586
+ export const PLAYER_HALF_WIDTH = 0.3;
587
+ export const PLAYER_HEIGHT = 1.8;
588
+ /** 「刚好贴着格子边」不算重叠:碰撞箱与方块共面时原版放得进去 */
589
+ export const HITBOX_EPS = 1e-3;
590
+
591
+ /**
592
+ * 实体碰撞箱与格子擦边也算重叠,原版 isUnobstructed 会拒绝放置。
593
+ * occupiedByMe 的中心格判断不足以覆盖宽 0.6 格的身体跨入邻格。
594
+ */
595
+ export function hitboxBlocks(bot: Bot, c: Cell): boolean {
596
+ const p = bot.entity?.position;
597
+ if (!p) return false;
598
+ const w = PLAYER_HALF_WIDTH;
599
+ const e = HITBOX_EPS;
600
+ return p.x - w < c.x + 1 - e && p.x + w > c.x + e
601
+ && p.z - w < c.z + 1 - e && p.z + w > c.z + e
602
+ && p.y < c.y + 1 - e && p.y + PLAYER_HEIGHT > c.y + e;
603
+ }
604
+
605
+ /**
606
+ * 放下去会不会占住身位。原版只对**有碰撞箱**的方块查 `isUnobstructed`:火把、树苗、
607
+ * 地毯这些放在自己身上照样成,不该为它们绕路。方块表里查不到这个名字(物品 id 与
608
+ * 方块 id 不同名的那几样)时按占位算。
609
+ */
610
+ export function materialCollides(bot: Bot, material: string): boolean {
611
+ const byName = bot.registry.blocksByName as Record<string, { boundingBox?: string } | undefined>;
612
+ return byName[material.replace(/^minecraft:/, '')]?.boundingBox !== 'empty';
613
+ }
614
+
615
+ /**
616
+ * 站得进人:这一格与它上面一格都容得下身子,脚下有实心底,三格都不是液体。
617
+ * 岩浆的 boundingBox 是 empty,液体判据缺一格就等于把人往里送。
618
+ */
619
+ export function standableCell(bot: Bot, c: Cell): boolean {
620
+ const feet = blockAtCell(bot, c);
621
+ const head = blockAtCell(bot, { x: c.x, y: c.y + 1, z: c.z });
622
+ const below = blockAtCell(bot, { x: c.x, y: c.y - 1, z: c.z });
623
+ if (!feet || !head || !below) return false;
624
+ if (feet.boundingBox !== 'empty' || head.boundingBox !== 'empty' || below.boundingBox !== 'block') return false;
625
+ return !LIQUIDS.has(feet.name) && !LIQUIDS.has(head.name) && !LIQUIDS.has(below.name);
626
+ }
627
+
628
+ /**
629
+ * 身子擦进了要放的那一格时,退到哪一格去。按「退开之后不再重叠」排序,只收站得住的。
630
+ *
631
+ * 候选只取同层四邻:上下两层要么是要放的那一格本身、要么得先垫先挖,不属于"退半步"。
632
+ */
633
+ export function stepOffCandidates(bot: Bot, c: Cell): Cell[] {
634
+ const feet = feetOf(bot);
635
+ return [
636
+ { x: feet.x + 1, y: feet.y, z: feet.z }, { x: feet.x - 1, y: feet.y, z: feet.z },
637
+ { x: feet.x, y: feet.y, z: feet.z + 1 }, { x: feet.x, y: feet.y, z: feet.z - 1 },
638
+ ]
639
+ // 站在格子正中时半宽 0.3 够不到一格开外,只要不与 `c` 同列就不会再重叠
640
+ .filter((s) => !(s.x === c.x && s.z === c.z))
641
+ .filter((s) => standableCell(bot, s))
642
+ .sort((a, b) => Math.hypot(b.x - c.x, b.z - c.z) - Math.hypot(a.x - c.x, a.z - c.z));
643
+ }
644
+
645
+ /**
646
+ * 把身子从 `c` 里挪出来。已经不重叠时直接 true;挪不开时 false,由调用方点名。
647
+ *
648
+ * 走的是寻路一格(GoalBlock),不是按方向键:方向键会和寻路器抢控制权,而这一步
649
+ * 常发生在寻路器正挂着目标的时候。
650
+ */
651
+ export async function stepOffCell(bot: Bot, ctx: SkillContext, c: Cell): Promise<boolean> {
652
+ if (!hitboxBlocks(bot, c)) return true;
653
+ for (const spot of stepOffCandidates(bot, c).slice(0, 2)) {
654
+ checkAbort(ctx);
655
+ try {
656
+ await gotoGoal(bot, new goals.GoalBlock(spot.x, spot.y, spot.z), ctx);
657
+ } catch (err) {
658
+ if (err instanceof Aborted) throw err;
659
+ continue;
660
+ }
661
+ if (!hitboxBlocks(bot, c)) return true;
662
+ }
663
+ return !hitboxBlocks(bot, c);
664
+ }
665
+
666
+ /** 放方块:只放贴得住的格,近的先;轮到自己站的那格就跳起来垫脚下,顶在脑袋上就先挪开一步 */
667
+ /** 几何试算报告目标区域内登记过的工作站和容器,仅报事实,不阻止操作。 */
668
+ export function stationNotes(bot: Bot, ctx: SkillContext, cells: readonly Cell[]): string[] {
669
+ const recs = ctx.chests?.inCells(dimensionOf(bot), cells) ?? [];
670
+ return recs.map((r) => {
671
+ const what = zhName(chestBlockName(r));
672
+ const origin = r.placedAt !== undefined
673
+ ? `你${ctx.clock ? ` ${ctx.clock(r.placedAt)}` : ''}放的`
674
+ : '你开过的';
675
+ const inside = r.items.length > 0 ? `,里面:${contentsText(r.items)}` : '';
676
+ return `这片罩住了${origin}${what} (${r.x}, ${r.y}, ${r.z})${inside}。`;
677
+ });
678
+ }
679
+
680
+ /**
681
+ * 放置结果以目标格的服务端回读为准;`bot.placeBlock` resolve 不代表服务端已接受
682
+ * (已在 mineflayer-fixes 里包成"回读确认才算数")。
683
+ */
684
+ /** 无法放置工作站时,说明所放物品、用途及可尝试的下一步。 */
685
+ export function placeNoSpotText(bot: Bot, block: string, station: Station | null): string {
686
+ const what = zhName(block);
687
+ const left = invCount(bot, (n) => n === block || n.endsWith(`_${block}`));
688
+ const forWhom = station ? `,是这一步给${station.label}用的` : '';
689
+ return `要放的是${what}${forWhom}(包里还有 ${left} 个),` +
690
+ '但脚边一圈八格没有一个「本身是空气、脚下又是实心」的位置 —— ' +
691
+ '站在坑里、贴着墙、脚边是水或草叶都会这样。' +
692
+ `先 goto 挪到一块开阔的平地再来,或者 excavate 把脚边那一格挖开腾出位置;` +
693
+ `${station ? `附近有现成的${station.label}的话直接走过去用也行。` : ''}`;
694
+ }
695
+
696
+ /** 刚放下的那条船在哪儿:只认落点附近 4 格内的,别把水面上远处那条报成这一条 */
697
+ export function nearestBoat(bot: Bot, near: Cell): Cell | null {
698
+ const at = new Vec3(near.x + 0.5, near.y, near.z + 0.5);
699
+ let best: Cell | null = null;
700
+ let bestD = 4;
701
+ for (const id of Object.keys(bot.entities)) {
702
+ const e = bot.entities[id];
703
+ if (!e?.position || !/boat|raft/.test(e.name ?? '')) continue;
704
+ const d = e.position.distanceTo(at);
705
+ if (d >= bestD) continue;
706
+ bestD = d;
707
+ // 实体坐标取整用 floor:船停在 x=1.5 时它就在 (1,…) 这一格,round 会报成隔壁那格
708
+ best = { x: Math.floor(e.position.x), y: Math.floor(e.position.y), z: Math.floor(e.position.z) };
709
+ }
710
+ return best;
711
+ }
712
+
713
+ /**
714
+ * 垫脚材料名单;显式空名单返回 null,表示禁用。
715
+ * 未设置时使用 policy.defaults(),与寻路器共用 cfg.scaffoldBlocks;未接 World 才用 FALLBACK_DEFAULTS。
716
+ */
717
+ export function scaffoldNames(ctx: SkillContext): string[] | null {
718
+ const listed = ctx.policy?.get().scaffold;
719
+ if (listed && listed.length === 0) return null;
720
+ const names = listed ?? (ctx.policy?.defaults() ?? FALLBACK_DEFAULTS).scaffold;
721
+ // 重力方块剔掉,与寻路器那一半同源(bridge 给 scafoldingBlocks 时也剔):垫下去失去支撑
722
+ // 就整块落地,垫不住。分叉的代价是受阻回执把沙砾、红沙当合法垫脚料原样念给她。
723
+ const usable = names.filter((n) => !isGravityBlock(n));
724
+ if (usable.length === 0) return null;
725
+ // 在建工地材料软降到末位,其他候选耗尽时仍可使用。
726
+ const material = siteMaterials(ctx);
727
+ if (material.size === 0) return usable;
728
+ return [...usable.filter((n) => !material.has(n)), ...usable.filter((n) => material.has(n))];
729
+ }
730
+
731
+ /**
732
+ * 在建的蓝图工地:已绑定锚点、游标还没走完的那些。
733
+ *
734
+ * 它们同时是三条规矩的取数口(全部只管"垫",不管走、挖、有意放置):工地建材垫脚
735
+ * 降位(`scaffoldNames`)、工地体积禁垫(`siteAtCell`)、收工回收(`reclaimSiteScaffold`)。
736
+ */
737
+ export function activeSites(ctx: SkillContext): BlueprintSite[] {
738
+ const desk = ctx.blueprints?.();
739
+ if (!desk) return [];
740
+ const out: BlueprintSite[] = [];
741
+ for (const key of desk.keys()) {
742
+ const site = desk.get(key);
743
+ if (site && site.anchor && site.cursor < site.plan.steps.length) out.push(site);
744
+ }
745
+ return out;
746
+ }
747
+
748
+ /** 在建工地要用到的物品名 */
749
+ export function siteMaterials(ctx: SkillContext): Set<string> {
750
+ const out = new Set<string>();
751
+ for (const site of activeSites(ctx)) {
752
+ for (const step of site.plan.steps) out.add(step.item);
753
+ }
754
+ return out;
755
+ }
756
+
757
+ /** 工地体积:锚点到锚点 + 尺寸 − 1,含端点两格 */
758
+ export function siteBox(site: BlueprintSite, anchor: PositionXYZ): { min: PositionXYZ; max: PositionXYZ } {
759
+ const s = site.blueprint.size_xyz;
760
+ return { min: [...anchor], max: [anchor[0] + s[0] - 1, anchor[1] + s[1] - 1, anchor[2] + s[2] - 1] };
761
+ }
762
+
763
+ export function inBox(box: { min: PositionXYZ; max: PositionXYZ }, c: { x: number; y: number; z: number }): boolean {
764
+ return c.x >= box.min[0] && c.x <= box.max[0]
765
+ && c.y >= box.min[1] && c.y <= box.max[1]
766
+ && c.z >= box.min[2] && c.z <= box.max[2];
767
+ }
768
+
769
+ /**
770
+ * 返回垫脚或搭路落点所在的工地,无命中时为 null。
771
+ * 蓝图施工与显式放置不使用此约束。
772
+ */
773
+ export function siteAtCell(ctx: SkillContext, cell: { x: number; y: number; z: number }): BlueprintSite | null {
774
+ // escape.active 时允许在工地内垫脚,放置仍记入 reclaimSiteScaffold 回收账。
775
+ if (ctx.escape.active) return null;
776
+ return siteAtCellAnywhere(ctx, cell);
777
+ }
778
+
779
+ /** 不带自救豁免的原判定:surface 数「为脱困垫进工地几块」用它 */
780
+ export function siteAtCellAnywhere(ctx: SkillContext, cell: { x: number; y: number; z: number }): BlueprintSite | null {
781
+ for (const site of activeSites(ctx)) {
782
+ if (inBox(siteBox(site, site.anchor!), cell)) return site;
783
+ }
784
+ return null;
785
+ }
786
+
787
+ export function siteRefusalText(site: BlueprintSite, cell: { x: number; y: number; z: number }): string {
788
+ return `${cellText(cell)} 在蓝图「${site.key}」工地里,不垫`;
789
+ }
790
+
791
+ /** 插一根用哪些;`null` = 这条关着 */
792
+ export function lightNames(ctx: SkillContext): string[] | null {
793
+ const listed = ctx.policy?.get().light;
794
+ if (listed && listed.length === 0) return null;
795
+ return listed ?? (ctx.policy?.defaults() ?? FALLBACK_DEFAULTS).light;
796
+ }
797
+
798
+ /**
799
+ * 在邻格脚下垫一块,好让脚边放置有实心底。只垫一格,不自动连铺。
800
+ *
801
+ * 只走四个正方向。斜角贴不住:参照方块是脚下那一格,而 mineflayer 把面向量按
802
+ * y→z→x 折成单轴(`generic_place.js` 的 `vectorToDirection`),`(1,0,1)` 发出去
803
+ * 是"+z 面",服务端把方块放在 `stand+(0,0,1)`,回读却盯着 `stand+(1,0,1)` ——
804
+ * 必然回读不到,还在别处留一块。
805
+ */
806
+ export async function padAdjacent(bot: Bot, ctx: SkillContext, block: string, station: Station | null): Promise<boolean> {
807
+ const stock = permittedStockFor(bot, scaffoldNames(ctx), '垫脚', ctx);
808
+ // 垫脚是"腾位置"这条路的最后一手:它也走不通时,受阻文案要说的是整件事
809
+ // (在放什么、为谁放、该怎么办),不是"垫脚料没有"这半句
810
+ if ('why' in stock) {
811
+ throw new SkillBlocked(`${placeNoSpotText(bot, block, station)}(想就地垫一格腾位置也不行:${stock.why})`);
812
+ }
813
+ let placed = false;
814
+ try {
815
+ const me = bot.entity.position.floored();
816
+ const stand = bot.blockAt(me.offset(0, -1, 0));
817
+ if (!stand || stand.boundingBox !== 'block') return false;
818
+ await bot.equip(stock.item, 'hand');
819
+ for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
820
+ checkAbort(ctx);
821
+ const spot = me.offset(dx, 0, dz);
822
+ const at = bot.blockAt(spot);
823
+ const below = bot.blockAt(spot.offset(0, -1, 0));
824
+ if (!at || at.name !== 'air' || !below || below.boundingBox === 'block') continue;
825
+ // 落的是 spot 下面那一格;在建工地体积里不垫
826
+ if (siteAtCell(ctx, { x: spot.x, y: spot.y - 1, z: spot.z })) continue;
827
+ try {
828
+ await bot.placeBlock(stand, new Vec3(dx, 0, dz));
829
+ } catch {
830
+ continue;
831
+ }
832
+ await sleep(200);
833
+ const now = bot.blockAt(spot.offset(0, -1, 0));
834
+ if (now && now.boundingBox === 'block') {
835
+ placed = true;
836
+ return true;
837
+ }
838
+ }
839
+ return false;
840
+ } finally {
841
+ stock.permit.finish(placed);
842
+ }
843
+ }
844
+
845
+ /**
846
+ * 脚边八选一放一块。**只给 craft/smelt 内部用**:她说的是"合成木镐",
847
+ * 工作台放哪不在她的意图里,World 自己找地方是机械兜底。
848
+ * 她显式指定位置的 place/use 不走这条,那条路的位置由她定。
849
+ *
850
+ * 兜底本身没错,错在它是哑的:放成之后把这一条从 `placedLedger` 摘掉,
851
+ * 由调用方在回执里点名报出来(craft 的 `made`、smelt 的自备工作站一句)。
852
+ * 台账剩下的才是寻路器垫脚/搭路的耗材。
853
+ */
854
+ export async function placeBlockNearby(
855
+ bot: Bot,
856
+ block: string,
857
+ ctx: SkillContext,
858
+ pad = false,
859
+ /** 这一块是为哪一族工作站放的;受阻文案据此说清"为谁放"(null = 没有上文) */
860
+ station: Station | null = null,
861
+ ): Promise<{ x: number; y: number; z: number; name: string; block: NonNullable<ReturnType<Bot['blockAt']>> }> {
862
+ const item = bot.inventory.items().find((i) => i.name === block || i.name.endsWith(`_${block}`));
863
+ if (!item) throw new SkillBlocked(`包里没有${zhName(block)}`);
864
+ await bot.equip(item, 'hand');
865
+ const me = bot.entity.position.floored();
866
+ let tried = 0;
867
+ let lastError = '';
868
+ let vanished = false;
869
+ // _genericPlace 不代发潜行状态;须先按 sneak,避免右键参照方块触发交互。
870
+ bot.setControlState('sneak', true);
871
+ try {
872
+ // 找脚边一圈:空气且下面是实心的位置
873
+ for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]) {
874
+ checkAbort(ctx);
875
+ const spot = me.offset(dx, 0, dz);
876
+ const at = bot.blockAt(spot);
877
+ const below = bot.blockAt(spot.offset(0, -1, 0));
878
+ if (!at || at.name !== 'air' || !below || below.boundingBox !== 'block') continue;
879
+ // 服务端撤回物品后应报告持有物失配,避免将其误报为位置不可放置。
880
+ const held = bot.heldItem;
881
+ if (!held || (held.name !== block && !held.name.endsWith(`_${block}`))) {
882
+ ctx.diag?.write({
883
+ lane: 'skill', event: 'place-not-held', taskId: ctx.taskId,
884
+ msg: `要放${zhName(block)},手上却是${held ? zhName(held.name) : '空的'}`,
885
+ data: { block, held: held?.name ?? null, inv: invCount(bot, (n) => n === block) },
886
+ });
887
+ throw new SkillBlocked(
888
+ `要放${zhName(block)},手上却是${held ? zhName(held.name) : '空的'}(包和服务器对不上)`,
889
+ );
890
+ }
891
+ tried++;
892
+ try {
893
+ await bot.placeBlock(below, new Vec3(0, 1, 0));
894
+ } catch (err) {
895
+ lastError = zhErrorText((err as Error).message);
896
+ continue;
897
+ }
898
+ // 回读:服务端认了才算放下了
899
+ await sleep(200);
900
+ const now = bot.blockAt(spot);
901
+ if (now && now.name !== 'air') {
902
+ ctx.diag?.write({
903
+ lane: 'skill', event: 'place-ok', taskId: ctx.taskId,
904
+ msg: `${zhName(now.name)}放在了 (${spot.x}, ${spot.y}, ${spot.z})`,
905
+ data: { block, spot: { x: spot.x, y: spot.y, z: spot.z }, readBack: now.name, tried },
906
+ });
907
+ forgetPlaced(bot, spot);
908
+ return { x: spot.x, y: spot.y, z: spot.z, name: now.name, block: now };
909
+ }
910
+ vanished = true;
911
+ ctx.diag?.write({
912
+ lane: 'skill', event: 'place-vanished', taskId: ctx.taskId,
913
+ msg: `${zhName(block)}放置没报错,回读 (${spot.x}, ${spot.y}, ${spot.z}) 还是空气`,
914
+ data: { block, spot: { x: spot.x, y: spot.y, z: spot.z }, held: invCount(bot, (n) => n === block) },
915
+ });
916
+ }
917
+ } finally {
918
+ bot.setControlState('sneak', false);
919
+ }
920
+ const left = invCount(bot, (n) => n === block || n.endsWith(`_${block}`));
921
+ const stock = `包里还有 ${left} 个`;
922
+ if (vanished) {
923
+ throw new SkillBlocked(`放了${zhName(block)},回读那一格还是空气,服务端没认。${stock}`);
924
+ }
925
+ if (tried > 0) {
926
+ throw new SkillBlocked(`脚边 ${tried} 个位置都放不下${zhName(block)}(${lastError || '放置被拒'})。${stock}`);
927
+ }
928
+ if (pad) {
929
+ const padded = await padAdjacent(bot, ctx, block, station);
930
+ if (padded) return placeBlockNearby(bot, block, ctx, false, station);
931
+ }
932
+ throw new SkillBlocked(placeNoSpotText(bot, block, station));
933
+ }
934
+
935
+ /** 一族工作站:认哪些方块、回执里怎么称呼、一座都没有时往哪指 */
936
+ export type Station = { kinds: readonly string[]; label: string; hint: string };
937
+
938
+ /** 合成要的那一座 */
939
+ export const CRAFTING_STATION: Station = {
940
+ kinds: ['crafting_table'], label: '工作台', hint: '先 craft 一个工作台(4 块木板)',
941
+ };
942
+
943
+ /** 烧炼要的那一座;哪种炉子能烧哪样由服务端说了算,这里只是"找个炉子" */
944
+ export const FURNACE_STATION: Station = {
945
+ kinds: ['furnace', 'smoker', 'blast_furnace'], label: '炉子', hint: '先 craft 一个熔炉(8 个圆石)',
946
+ };
947
+
948
+ export function findStations(
949
+ bot: Bot,
950
+ range: number,
951
+ kinds: readonly string[],
952
+ ): Array<{ x: number; y: number; z: number; name: string; d: number }> {
953
+ const ids = kinds
954
+ .map((n) => (bot.registry.blocksByName as Record<string, { id: number } | undefined>)[n]?.id)
955
+ .filter((id): id is number => id !== undefined);
956
+ if (ids.length === 0) return [];
957
+ const me = bot.entity.position;
958
+ const out: Array<{ x: number; y: number; z: number; name: string; d: number }> = [];
959
+ for (const p of bot.findBlocks({ matching: ids, maxDistance: range, count: 16 })) {
960
+ const b = bot.blockAt(p);
961
+ if (!b) continue;
962
+ out.push({ x: p.x, y: p.y, z: p.z, name: b.name, d: p.distanceTo(me) });
963
+ }
964
+ out.sort((a, b) => a.d - b.d);
965
+ return out;
966
+ }
967
+
968
+ export type StationAt = {
969
+ x: number; y: number; z: number;
970
+ name: string;
971
+ block: NonNullable<ReturnType<Bot['blockAt']>>;
972
+ /** 用了哪一座、包里还剩几个:这一步在世界里做的事,由调用方原样写进回执 */
973
+ note: string;
974
+ /** 这一座是这一步自备放下的(已入容器账本,placedAt 可查) */
975
+ placed: boolean;
976
+ };
977
+
978
+ /**
979
+ * 优先使用可及的现成工作站,否则就地放自备工作站;没有自备时才走向现成工作站。
980
+ * 回执报告现成位置、距离和库存;自备工作站由 note 单独报告,不计寻路耗材。
981
+ */
982
+ export async function ensureStation(
983
+ bot: Bot,
984
+ station: Station,
985
+ ctx: SkillContext,
986
+ // skip 报出「这一座为什么不能用」(比如炉子还烧着别的);null = 能用。
987
+ // 被跳过的事实全部进回执/受阻文案——跳过是事实判断(它干不了这活),不是权衡
988
+ opts?: { skip?: (c: { x: number; y: number; z: number }) => string | null },
989
+ ): Promise<StationAt> {
990
+ const { kinds, label, hint } = station;
991
+ const stock = (): number => kinds.reduce((n, k) => n + invCount(bot, (x) => x === k), 0);
992
+ const skipped: string[] = [];
993
+ let near: ReturnType<typeof findStations>[number] | null = null;
994
+ for (const s of findStations(bot, 32, kinds)) {
995
+ const why = opts?.skip?.(s) ?? null;
996
+ if (why) { skipped.push(why); continue; }
997
+ near = s;
998
+ break;
999
+ }
1000
+ const skipNote = skipped.length > 0 ? `${skipped.join(';')};` : '';
1001
+ const nearWhere = near ? `(${near.x}, ${near.y}, ${near.z}),约 ${Math.round(near.d)} 格外` : null;
1002
+ const carried = kinds.find((k) => invCount(bot, (n) => n === k) > 0) ?? null;
1003
+ const inReach = near !== null && near.d <= PLACE_REACH;
1004
+ if (near && (carried === null || inReach)) {
1005
+ await gotoGoal(bot, new goals.GoalNear(near.x, near.y, near.z, 2), ctx);
1006
+ const block = bot.blockAt(new Vec3(near.x, near.y, near.z));
1007
+ if (block && kinds.includes(block.name)) {
1008
+ ctx.diag?.write({
1009
+ lane: 'craft', event: 'station-reuse', taskId: ctx.taskId,
1010
+ msg: `用现成的${zhName(block.name)} (${near.x}, ${near.y}, ${near.z})`,
1011
+ data: { at: { x: near.x, y: near.y, z: near.z }, name: block.name, dist: near.d, carried: stock() },
1012
+ });
1013
+ return {
1014
+ x: near.x, y: near.y, z: near.z, name: block.name, block, placed: false,
1015
+ note: skipNote + (inReach
1016
+ ? `用了手边现成的${zhName(block.name)} ${nearWhere}(包里还有 ${stock()} 个,没动)`
1017
+ : `包里没有${label},走过去用了现成的那个 ${nearWhere}`),
1018
+ };
1019
+ }
1020
+ }
1021
+ if (!carried) {
1022
+ throw new SkillBlocked(skipNote + (near
1023
+ ? `走到 (${near.x}, ${near.y}, ${near.z}) 那一格,${label}已经不在了;包里也没有。${hint}`
1024
+ : `32 格内没有${skipped.length > 0 ? '别的' : ''}${kinds.map((k) => zhName(k)).join('或')},包里也没有。${hint}`));
1025
+ }
1026
+ const spot = await placeBlockNearby(bot, carried, ctx, true, station);
1027
+ if (spot.name !== carried) {
1028
+ throw new SkillBlocked(`要放${zhName(carried)},那一格回读到的是${zhName(spot.name)},没放成`);
1029
+ }
1030
+ // 自备工作站按 placedAt 登记,供几何试算报告区域内已有设施。
1031
+ ctx.chests?.rememberStation(dimensionOf(bot), spot, spot.name, Date.now());
1032
+ return {
1033
+ x: spot.x, y: spot.y, z: spot.z, name: spot.name, block: spot.block, placed: true,
1034
+ note: skipNote + `放下了一个${zhName(spot.name)} (${spot.x}, ${spot.y}, ${spot.z})(包里还有 ${stock()} 个;` +
1035
+ `${nearWhere ? `附近现成的那个在 ${nearWhere},这趟没去` : `32 格内没有现成的${label}`})`,
1036
+ };
1037
+ }
1038
+