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,1169 @@
1
+ /**
2
+ * 搭起来:build 按她点的格子或形状放,蓝图形态按编译好的步序施工并回读对账。
3
+ *
4
+ * 落脚、贴面与补光在 placement.ts;这里管的是放什么、放成什么形状、放到第几步。
5
+ */
6
+ import type { Bot } from 'mineflayer';
7
+ import pathfinderPkg from 'mineflayer-pathfinder';
8
+ import {
9
+ Aborted, SkillBlocked, checkAbort, sleep, type BlueprintSite, type BlueprintSurvey,
10
+ type SkillContext,
11
+ } from './skill-context.ts';
12
+ import {
13
+ AIR_NAMES, EXCAVATE_CELL_CAP, FACE_ZH, LIQUIDS, NEIGHBORS6, NO_PLACE_REFERENCE, PLACE_REACH,
14
+ SHAPE_ZH, blockAtCell, cellKeyOf, cellText, dimensionOf, faceText, feetOf, nearLavaAt, refAt,
15
+ refCellOf, resolveAt, skyBlocked, solidAt,
16
+ } from './cell-facts.ts';
17
+ import {
18
+ Upkeep, buildSpots, ensureHolding, footprintOf, footprintScene, gotoPlaceable, hitboxBlocks,
19
+ inBox, jumpPlaceBelow, matchPlacedMaterialName, materialCollides, occupantOf, occupantText,
20
+ occupiedByMe, permitPlacement, placeIntoCell, siteAtCellAnywhere, siteBox, stationNotes,
21
+ stepOffCell, sweepDrops, type BuildSpot,
22
+ } from './placement.ts';
23
+ import { digBlock, gotoGoal, readStamp, settleOnGround } from './travel.ts';
24
+ import { zhName } from './names.ts';
25
+ import { equipToolFor } from './tools.ts';
26
+ import { bodyInWater, headInWater } from './terrain.ts';
27
+ import { type BlueprintCall, type PlaceCall } from './receipt.ts';
28
+ import { isBoat, skillUse } from './skills-interact.ts';
29
+ import { invCount, invSnapshot } from './inventory.ts';
30
+ import { matchMaterialName } from './chests.ts';
31
+ import { type BlockFace, type Cell } from './geometry.ts';
32
+ import {
33
+ billForSteps, blueprintProgress, blueprintStepStateMatches, diffBlueprint,
34
+ renderBlueprintAdvisories, stepCountThroughLayer, stepToBuildCall, summarizeReadback, toWorld,
35
+ type BlueprintCheckCell, type BlueprintConflict, type BlueprintDiff, type BlueprintStep,
36
+ type ItemTally, type ReadbackEntry,
37
+ } from './blueprint-plan.ts';
38
+ import {
39
+ blockIdOf, normalizeBlockName, renderLayerMap, type NormalizedBlueprint, type PositionXYZ,
40
+ } from './blueprint.ts';
41
+ import { skillExcavate } from './skills-dig.ts';
42
+ import { placedLedgerOf } from './placed-ledger.ts';
43
+
44
+ const { goals } = pathfinderPkg;
45
+
46
+ /** 陆上 surface 一次爬升的时限;到点如实报爬到哪 */
47
+ export const SURFACE_CLIMB_MS = 90_000;
48
+
49
+ /**
50
+ * 陆上 surface:头顶有实心遮盖就挖开头顶两格再垫脚上一格,循环到露天。
51
+ * 头顶压着液体不捅穿;挖不动(基岩)、没垫脚方块、限时到,都带着已爬格数受阻。
52
+ */
53
+ export async function skillSurfaceLand(bot: Bot, ctx: SkillContext): Promise<string> {
54
+ if (!skyBlocked(bot, feetOf(bot).x, feetOf(bot).y + 2, feetOf(bot).z)) {
55
+ return `我已经在露天了;${surfaceStateText(bot)}`;
56
+ }
57
+ ctx.escape.active = true;
58
+ const keep = new Upkeep(bot, ctx);
59
+ const deadline = Date.now() + SURFACE_CLIMB_MS;
60
+ let climbed = 0;
61
+ let paddedInSite = 0;
62
+ const where = (): string => `,我在 ${cellText(feetOf(bot))}`;
63
+ for (;;) {
64
+ checkAbort(ctx);
65
+ await settleOnGround(bot, ctx, 1_500);
66
+ const feet = feetOf(bot);
67
+ if (!skyBlocked(bot, feet.x, feet.y + 2, feet.z)) break;
68
+ if (Date.now() > deadline) {
69
+ throw new SkillBlocked(`我往上爬了 ${climbed} 格还没到露天,限时到了${where()}`);
70
+ }
71
+ for (const dy of [1, 2]) {
72
+ const cc = { x: feet.x, y: feet.y + dy, z: feet.z };
73
+ const b = blockAtCell(bot, cc);
74
+ if (!b) throw new SkillBlocked(`我往上爬了 ${climbed} 格,头顶 (${cc.x}, ${cc.y}, ${cc.z}) 区块没加载${where()}`);
75
+ if (LIQUIDS.has(b.name)) {
76
+ throw new SkillBlocked(`我往上爬了 ${climbed} 格,头顶 (${cc.x}, ${cc.y}, ${cc.z}) 是${zhName(b.name)},不敢捅穿${where()}`);
77
+ }
78
+ if (b.boundingBox !== 'block') continue;
79
+ if (b.diggable === false) throw new SkillBlocked(`我往上爬了 ${climbed} 格,头顶是${zhName(b.name)},挖不动${where()}`);
80
+ const above = blockAtCell(bot, { x: cc.x, y: cc.y + 1, z: cc.z });
81
+ if (above && LIQUIDS.has(above.name)) {
82
+ throw new SkillBlocked(`我往上爬了 ${climbed} 格,(${cc.x}, ${cc.y}, ${cc.z}) 上面压着${zhName(above.name)},不敢捅穿${where()}`);
83
+ }
84
+ // 挖开前检查六个正邻格,覆盖正上方柱检查不到的侧邻岩浆。
85
+ if (nearLavaAt(bot, cc)) {
86
+ throw new SkillBlocked(`我往上爬了 ${climbed} 格,头顶 (${cc.x}, ${cc.y}, ${cc.z}) 旁边贴着岩浆,不敢捅${where()}`);
87
+ }
88
+ await equipToolFor(bot, b, ctx);
89
+ await digBlock(bot, b, ctx);
90
+ }
91
+ // 垫脚上升前再对新落脚格与新头顶格各查一圈:柱子本身可能全程无料可挖(挖前闸
92
+ // 摸不到),挖邻格也可能放出流动岩浆 —— 人升上去才贴上就是死#3 的形状
93
+ for (const dy of [1, 2]) {
94
+ const cc = { x: feet.x, y: feet.y + dy, z: feet.z };
95
+ if (nearLavaAt(bot, cc)) {
96
+ throw new SkillBlocked(`我往上爬了 ${climbed} 格,再上一格的落脚处 (${cc.x}, ${cc.y}, ${cc.z}) 旁边贴着岩浆,不敢上去${where()}`);
97
+ }
98
+ }
99
+ if (!(await keep.climb())) {
100
+ throw new SkillBlocked(`我往上爬了 ${climbed} 格,${keep.why('climb')},上不去了${where()}`);
101
+ }
102
+ // 垫进脚下的这一格落在工地体积里(自救豁免放行的):记数,回执里说清楚
103
+ if (siteAtCellAnywhere(ctx, feet)) paddedInSite++;
104
+ climbed++;
105
+ }
106
+ return `我爬到露天了,上来 ${climbed} 格${where()}`
107
+ + (paddedInSite > 0 ? `;为脱困在蓝图工地体积里垫了 ${paddedInSite} 块` : '')
108
+ + `;${surfaceStateText(bot)}`;
109
+ }
110
+
111
+ /** 水中 surface 的完成条件:身体已离水,脚下有可站立支撑,并且短时复读不回水。 */
112
+ export function hasDryFooting(bot: Bot): boolean {
113
+ const entity = bot.entity as Bot['entity'] & { isInWater?: boolean; isInLava?: boolean };
114
+ return entity.onGround
115
+ && entity.isInWater !== true
116
+ && entity.isInLava !== true
117
+ && !headInWater(bot)
118
+ && !bodyInWater(bot);
119
+ }
120
+
121
+ /** surface 的可观测后置条件;水中分支的成功不蕴含看得到天空。 */
122
+ export function surfaceStateText(bot: Bot): string {
123
+ const feet = feetOf(bot);
124
+ const outOfLiquid = !headInWater(bot) && !bodyInWater(bot);
125
+ const standing = hasDryFooting(bot);
126
+ const skyVisible = !skyBlocked(bot, feet.x, feet.y + 2, feet.z);
127
+ const finalY = Number(bot.entity.position.y.toFixed(2));
128
+ return `out_of_liquid=${outOfLiquid},standing=${standing},sky_visible=${skyVisible},final_y=${finalY}`;
129
+ }
130
+
131
+ export async function stableDryFooting(bot: Bot, ctx: SkillContext): Promise<boolean> {
132
+ if (!hasDryFooting(bot)) return false;
133
+ await sleep(300);
134
+ checkAbort(ctx);
135
+ return hasDryFooting(bot);
136
+ }
137
+
138
+ export async function skillBuild(bot: Bot, call: PlaceCall, ctx: SkillContext): Promise<string> {
139
+ const spots = buildSpots(bot, call);
140
+ const cells = spots.map((s) => s.cell);
141
+ const shape = 'on' in call ? undefined : call.shape;
142
+ const faceGiven = spots.some((s) => s.face !== null);
143
+ const one = cells.length === 1;
144
+ // 一处一处下的单(贴面、不写 shape 的格子清单)也一处一处回报;形状是连片的,按形状报
145
+ const listed = shape === undefined && !one;
146
+ const what = one ? cellText(cells[0]) : shape ? SHAPE_ZH[shape] : `这 ${cells.length} 格`;
147
+ const spanText = one ? cellText(cells[0])
148
+ : shape ? `${SHAPE_ZH[shape]}那 ${cells.length} 格` : `这 ${cells.length} 格`;
149
+ const label = zhName(call.material);
150
+ // BoatItem 由服务端沿玩家视线处理 use_item,不响应普通方块的 use_item_on。
151
+ // 此处指向 useBoat,不代为放置。
152
+ if (isBoat(call.material)) {
153
+ throw new SkillBlocked(
154
+ `${label}不是方块,build 放不出来(服务端按视线射线生成,只认「使用物品」)`,
155
+ [`改用 {"skill":"use","item":"${call.material}","at":[x,y,z]} 指一格水面/地面`],
156
+ );
157
+ }
158
+ const stock = () => invCount(bot, (n) => matchMaterialName(bot.registry, call.material, n));
159
+ const classify = () => {
160
+ const todo: BuildSpot[] = [];
161
+ /** 开工前就定了局的那些格:一处一句,给逐处回报用 */
162
+ const settled = new Map<string, string>();
163
+ // 已经是目标方块的格计为已完成。
164
+ let already = 0;
165
+ let occupied = 0;
166
+ let unloaded = 0;
167
+ for (const s of spots) {
168
+ const b = blockAtCell(bot, s.cell);
169
+ if (!b) { unloaded++; settled.set(cellKeyOf(s.cell), '区块没加载'); }
170
+ else if (matchPlacedMaterialName(bot, call.material, b.name)) {
171
+ already++;
172
+ settled.set(cellKeyOf(s.cell), `本来就是${label}`);
173
+ }
174
+ else if (b.boundingBox === 'block') { occupied++; settled.set(cellKeyOf(s.cell), `现在是${zhName(b.name)}`); }
175
+ else todo.push(s);
176
+ }
177
+ return { todo, already, occupied, unloaded, settled };
178
+ };
179
+ const first = classify();
180
+ if (call.dryRun) {
181
+ const have = stock();
182
+ const bits = [
183
+ `共 ${cells.length} 格`,
184
+ `${first.already} 格已经是${label}`,
185
+ `${first.occupied} 格被别的方块占着`,
186
+ `要放 ${first.todo.length} 块`,
187
+ have >= first.todo.length ? `包里${label}有 ${have} 个,够` : `包里${label}只有 ${have} 个,差 ${first.todo.length - have}`,
188
+ ];
189
+ // 贴面形态的"贴得住不"在出发前就读得出来:参照方块不是实心的,那一处根本贴不上
190
+ const noRef = spots.filter((s) => s.face !== null && !solidAt(bot, refCellOf(s.cell, s.face)));
191
+ if (noRef.length > 0) {
192
+ bits.push(`${noRef.length} 处贴不住(${noRef.slice(0, 3).map((s) => cellText(refCellOf(s.cell, s.face as BlockFace))).join('、')} 不是实心方块)`);
193
+ }
194
+ if (first.unloaded > 0) bits.push(`${first.unloaded} 格区块没加载`);
195
+ const stations = stationNotes(bot, ctx, cells);
196
+ return `试算${what}: ${bits.join(';')}。${stations.join('')}没动工`;
197
+ }
198
+ if (first.todo.length === 0) {
199
+ if (first.already === cells.length) {
200
+ return one ? `${cellText(cells[0])} 本来就是${label}` : `${spanText}已经都是${label}了`;
201
+ }
202
+ const bits = [`${first.already} 格已经是${label}`, `${first.occupied} 格被别的方块占着`];
203
+ if (first.unloaded > 0) bits.push(`${first.unloaded} 格区块没加载`);
204
+ const blockers = cells
205
+ .filter((c) => !matchPlacedMaterialName(bot, call.material, blockAtCell(bot, c)?.name ?? ''))
206
+ .slice(0, 3)
207
+ .map((c) => occupantText(bot, c));
208
+ // 标出回读时刻,便于区分稍后核验得到的新读数。
209
+ throw new SkillBlocked(
210
+ `一块都没放上:${spanText}没有一格空着(${bits.join(',')};读于 ${readStamp()})`,
211
+ blockers,
212
+ );
213
+ }
214
+ if (stock() === 0) throw new SkillBlocked(`包里没有${label}`);
215
+
216
+ // 本单落点登记在案:这一趟寻路垫在落点之外的那些块是耗材,由执行器照报
217
+ for (const c of cells) ctx.intended?.add(cellKeyOf(c));
218
+ const total = first.todo.length;
219
+ const remaining = new Map(first.todo.map((s) => [cellKeyOf(s.cell), s]));
220
+ /** 放成了的那些格:贴的是哪一面 */
221
+ const stuck = new Map<string, string>();
222
+ /** 这一趟真动过手的那些格:没放上时才说得出"都试过了" */
223
+ const tried = new Set<string>();
224
+ let placed = 0;
225
+ /** 收不了工时的原因;空串 = 全放完了 */
226
+ let halt = '';
227
+ while (remaining.size > 0) {
228
+ checkAbort(ctx);
229
+ const item = bot.inventory.items()
230
+ .find((i) => matchMaterialName(bot.registry, call.material, i.name));
231
+ if (!item) { halt = `${label}用完了`; break; }
232
+ await bot.equip(item, 'hand');
233
+ const me = bot.entity.position;
234
+ let feet = feetOf(bot);
235
+ // 边界:贴得住的那些格才放得上。指名了面就只认那一面的参照方块,没指名就看六个面里有没有实心的
236
+ const frontier = [...remaining.values()]
237
+ .filter((s) => (s.face !== null
238
+ ? refAt(bot, refCellOf(s.cell, s.face))
239
+ : NEIGHBORS6.some(([dx, dy, dz]) => refAt(bot, { x: s.cell.x + dx, y: s.cell.y + dy, z: s.cell.z + dz }))))
240
+ // 自下而上,同层里近的先。按三维距离排会把几何层的顺序冲掉:同一柱先放上高处那格,
241
+ // 低处那格就成了「贴着上面那格的下面吊着盖」,够不着的高处还要逼寻路垫脚
242
+ // (0824 场 72 个跨层 build 段里 50 个先高后低)。
243
+ .sort((a, b) => a.cell.y - b.cell.y
244
+ || Math.hypot(a.cell.x + 0.5 - me.x, a.cell.z + 0.5 - me.z)
245
+ - Math.hypot(b.cell.x + 0.5 - me.x, b.cell.z + 0.5 - me.z));
246
+ if (frontier.length === 0) {
247
+ // 六个面都贴不住时,还得说出占着那一格的是自己的脑袋——不然读起来像世界的问题
248
+ const head = [...remaining.values()]
249
+ .every((s) => s.cell.x === feet.x && s.cell.z === feet.z && s.cell.y === feet.y + 1);
250
+ // 耕地与作物不能作为参照面;须区别此限制与没有实心方块。
251
+ const soilRef = [...remaining.values()]
252
+ .flatMap((s) => NEIGHBORS6.map(([dx, dy, dz]) =>
253
+ blockAtCell(bot, { x: s.cell.x + dx, y: s.cell.y + dy, z: s.cell.z + dz })))
254
+ .find((b) => b !== null && NO_PLACE_REFERENCE.has(b.name));
255
+ halt = head
256
+ ? `剩下的那一格 ${cellText({ x: feet.x, y: feet.y + 1, z: feet.z })} 顶在我脑袋上,` +
257
+ '而且六个面都没有能贴着放的实心方块'
258
+ : soilRef
259
+ ? `剩下的 ${remaining.size} 格贴不住:挨着的是${zhName(soilRef.name)},` +
260
+ '原版不收耕地与作物当放置参照面,这一下发出去服务端必拒,没发'
261
+ : faceGiven
262
+ ? `剩下的 ${remaining.size} 格,指名的那一面不是实心方块,贴不住`
263
+ : `剩下的 ${remaining.size} 格六个面都没有能贴着放的实心方块`;
264
+ break;
265
+ }
266
+ let progressed = false;
267
+ let headOnly = 0;
268
+ for (const s of frontier) {
269
+ checkAbort(ctx);
270
+ const c = s.cell;
271
+ const key = cellKeyOf(c);
272
+ if (occupiedByMe(bot, c)) {
273
+ // 脚或头占据落点时,距离近仍须重新落位,避免身体阻挡放置。
274
+ await gotoPlaceable(bot, s, ctx);
275
+ feet = feetOf(bot);
276
+ }
277
+ if (occupiedByMe(bot, c)) {
278
+ if (c.y === feet.y + 1) { headOnly++; continue; }
279
+ if (s.face === null || s.face === 'up') {
280
+ // 挪不开的脚下格才跳起来垫;贴的永远是脚下那一块的上面,指名了别的面时这条路不适用
281
+ if (!(await ensureHolding(bot, call.material))) { halt = `${label}用完了`; break; }
282
+ const permit = permitPlacement(ctx, bot.heldItem?.name ?? item.name);
283
+ if (!permit.ok) { halt = permit.reason; break; }
284
+ let landed = false;
285
+ try {
286
+ landed = await jumpPlaceBelow(bot, ctx, call.material);
287
+ } finally {
288
+ permit.finish(landed);
289
+ }
290
+ if (landed) {
291
+ remaining.delete(key);
292
+ stuck.set(key, faceText(refCellOf(c, 'up'), 'up'));
293
+ progressed = true;
294
+ break;
295
+ }
296
+ tried.add(key);
297
+ continue;
298
+ }
299
+ }
300
+ const p = bot.entity.position;
301
+ if (Math.hypot(c.x + 0.5 - p.x, c.y - p.y, c.z + 0.5 - p.z) > PLACE_REACH
302
+ && !(await gotoPlaceable(bot, s, ctx))) continue;
303
+ // 对占身位的材料,中心格外的碰撞箱擦边也须避开;gotoPlaceable 只保证可及距离。
304
+ if (materialCollides(bot, call.material) && hitboxBlocks(bot, c)
305
+ && !(await stepOffCell(bot, ctx, c))) { tried.add(key); continue; }
306
+ if (!(await ensureHolding(bot, call.material))) { halt = `${label}用完了`; break; }
307
+ const permit = permitPlacement(ctx, bot.heldItem?.name ?? item.name);
308
+ if (!permit.ok) { halt = permit.reason; break; }
309
+ tried.add(key);
310
+ let landed: BlockFace | null = null;
311
+ try {
312
+ landed = await placeIntoCell(bot, c, call.material, ctx, s.face ?? undefined);
313
+ } finally {
314
+ permit.finish(landed !== null);
315
+ }
316
+ if (landed) {
317
+ remaining.delete(key);
318
+ stuck.set(key, faceText(refCellOf(c, landed), landed));
319
+ progressed = true;
320
+ break;
321
+ }
322
+ }
323
+ if (halt) break;
324
+ if (progressed) {
325
+ placed++;
326
+ ctx.progress?.(placed, total);
327
+ } else if (headOnly > 0 && headOnly === frontier.length) {
328
+ halt = `剩 ${remaining.size} 格顶在我脑袋上,挪了一步也没挪开`;
329
+ break;
330
+ } else {
331
+ // 点名占位的是谁:"剩 N 格放不上"读不出下一步该拆什么
332
+ halt = `剩 ${remaining.size} 格放不上,${faceGiven ? '指名的那一面' : '六个面都'}试过了:` +
333
+ `${frontier.slice(0, 3).map((s) => occupantText(bot, s.cell)).join('、')}`;
334
+ break;
335
+ }
336
+ }
337
+ /** 逐处回报:一处贴不住不牵连别处 */
338
+ const spotLine = (s: BuildSpot): string => {
339
+ const key = cellKeyOf(s.cell);
340
+ const ok = stuck.get(key);
341
+ if (ok) return `${cellText(s.cell)} ${ok}放上了`;
342
+ const settled = first.settled.get(key);
343
+ if (settled) return `${cellText(s.cell)} ${settled}`;
344
+ if (s.face !== null && !solidAt(bot, refCellOf(s.cell, s.face))) {
345
+ const ref = refCellOf(s.cell, s.face);
346
+ const b = blockAtCell(bot, ref);
347
+ return `${cellText(s.cell)} 贴不住:${cellText(ref)} 是${b ? zhName(b.name) : '没加载的区块'},不是实心方块`;
348
+ }
349
+ if (!tried.has(key)) return `${cellText(s.cell)} 没轮到:${halt}`;
350
+ return `${cellText(s.cell)} ${s.face ? `${FACE_ZH[s.face]}面试过了` : '六个面都试过了'},` +
351
+ `服务端没认:${occupantOf(bot, s.cell)}`;
352
+ };
353
+ const notes: string[] = [];
354
+ if (first.already > 0) notes.push(`${first.already} 格本来就是${label},跳过`);
355
+ if (first.occupied > 0) notes.push(`${first.occupied} 格被别的方块占着,跳过`);
356
+ if (first.unloaded > 0) notes.push(`${first.unloaded} 格区块没加载`);
357
+ const frame = portalFrameTally(bot, call.material, [...stuck.keys()]);
358
+ if (frame) notes.push(frame);
359
+ const pillar = placed > 0 ? pillarStandNote(bot, cells) : null;
360
+ if (pillar) notes.push(pillar);
361
+ const where = `。现在人在 ${cellText(feetOf(bot))}`;
362
+ const tail = `${notes.length > 0 ? `;${notes.join(';')}` : ''}${where}`;
363
+ // 一处一处下的单,一处一处回报:一处贴不住不牵连另外几处
364
+ const spotLines = shape === undefined ? spots.map(spotLine) : [];
365
+ if (halt && placed === 0) {
366
+ // 床/门这类占两格的:她从快照里看不到"哪儿有连续两格空位",把附近够用的位置报出来
367
+ const fp = footprintOf(bot, call.material);
368
+ const scene = fp === 'single' ? [] : footprintScene(bot, first.todo[0].cell, call.material, fp);
369
+ throw new SkillBlocked(`一块都没放上:${halt}${tail}`, [...spotLines, ...scene], 'server');
370
+ }
371
+ // 缺口以 remaining 中计划放置但未完成的格计;原已存在的目标方块不计缺口。
372
+ const gap = [...remaining.values()].map((s) => cellText(s.cell));
373
+ if (gap.length > 0) {
374
+ ctx.partial?.(`${label}还差 ${gap.length} 处没放上(${gap.slice(0, 6).join('、')}${gap.length > 6 ? '…' : ''})`);
375
+ }
376
+ if (listed) return `放上了 ${placed}/${cells.length} 处:${spotLines.join(';')}${where}`;
377
+ const along = shape ? `沿${SHAPE_ZH[shape]}` : '';
378
+ if (halt) return `${one ? '' : along}放了 ${placed}/${total} 块${label},停在:${halt}${tail}`;
379
+ const how = stuck.get(cellKeyOf(cells[0]));
380
+ return one
381
+ ? `${cellText(cells[0])} 放下了${label}${how ? `,${how}` : ''};包里还有 ${stock()} 个${tail}`
382
+ : `${along}放好了 ${total} 块${label}${tail}`;
383
+ }
384
+
385
+ /**
386
+ * 单列柱完成后报告当前所站列;已在目标列则返回 null。
387
+ * build 可从旁边的临时支撑柱放置,不保证站上目标柱;攀上目标柱使用 tunnel 的塔形态。
388
+ */
389
+ export function pillarStandNote(bot: Bot, cells: readonly Cell[]): string | null {
390
+ const [c0] = cells;
391
+ if (cells.length < 2 || !cells.every((c) => c.x === c0.x && c.z === c0.z)) return null;
392
+ const feet = feetOf(bot);
393
+ if (feet.x === c0.x && feet.z === c0.z) return null;
394
+ return `这一根立在 (${c0.x}, ${c0.z}) 那一列,人不在它上面(横着差 ` +
395
+ `${Math.round(Math.hypot(feet.x - c0.x, feet.z - c0.z))} 格),中间是空的;` +
396
+ '要站上自己搭的那一根,用 tunnel 的塔(at 放正上方、不带 spiral)';
397
+ }
398
+
399
+ /** 门框材料集合;放置回执须区分黑曜石与哭泣的黑曜石。 */
400
+ export const PORTAL_FRAME_MATERIALS = new Set(['obsidian', 'crying_obsidian']);
401
+
402
+ /**
403
+ * 门框那一批放完之后的机械核对:逐格回读真名,报「实际是:黑曜石×N」。
404
+ * 走 registry 的精确名比对,不经 matchMaterialName —— 这一句的价值全在它不模糊。
405
+ */
406
+ export function portalFrameTally(bot: Bot, material: string, placedKeys: readonly string[]): string | null {
407
+ if (!PORTAL_FRAME_MATERIALS.has(material) || placedKeys.length === 0) return null;
408
+ const tally = new Map<string, number>();
409
+ for (const key of placedKeys) {
410
+ const [x, y, z] = key.split(',').map(Number);
411
+ const b = blockAtCell(bot, { x, y, z });
412
+ const name = b?.name ?? '(读不到)';
413
+ tally.set(name, (tally.get(name) ?? 0) + 1);
414
+ }
415
+ const bits = [...tally].map(([name, n]) => `${name === '(读不到)' ? name : zhName(name)}×${n}`);
416
+ return `门框材料实际是:${bits.join('、')}`;
417
+ }
418
+
419
+ // ── 蓝图施工 ──────────────────────────────────────────────────────────────────
420
+
421
+ /** 回读一次最多读几格;超了就读前面这些,回执如实说读了多少 */
422
+ export const BLUEPRINT_READBACK_CAP = 4096;
423
+ /** dryRun 的逐层字符图最多打几行(含图例);再多她读不完,也是常驻上下文开销 */
424
+ export const BLUEPRINT_MAP_ROWS = 40;
425
+ /** 连着这么多步没推进就提前收工:再往下多半是同一处障碍,空转的每一步都是钱 */
426
+ export const BLUEPRINT_FAIL_STREAK = 5;
427
+ /** 清场受阻时最多把几段 excavate 回执并进现场;再多是常驻上下文开销 */
428
+ export const CLEAR_NOTE_CAP = 4;
429
+
430
+ /**
431
+ * 清场回读后允许再清一轮的残留上限。超过它就抛:那说明拖住清场的不是蔓延速度,
432
+ * 再清一轮也追不上。轮数硬上限一轮 —— 无上限的重试就是真死循环。
433
+ */
434
+ export const CLEAR_RESIDUE_CAP = 5;
435
+
436
+ /** 抛错时残留格最多逐格列几行;剩下的报个数,不无界往上下文里灌 */
437
+ export const CLEAR_RESIDUE_LIST_CAP = 24;
438
+
439
+ /** 残留格逐格摆出来:哪一格、现在是什么、该是什么 */
440
+ export function conflictCellLines(conflicts: readonly BlueprintConflict[]): string[] {
441
+ const zh = (state: string): string => zhName(blockIdOf(state).replace('minecraft:', ''));
442
+ const lines = conflicts.slice(0, CLEAR_RESIDUE_LIST_CAP).map((c) =>
443
+ `${cellText({ x: c.pos[0], y: c.pos[1], z: c.pos[2] })} 现在是${zh(c.actual)},该是${zh(c.expect)}`);
444
+ const rest = conflicts.length - lines.length;
445
+ return rest > 0 ? [...lines, `另外 ${rest} 格同样没清掉`] : lines;
446
+ }
447
+
448
+ /**
449
+ * 清场回执里表示「这几格没挖成」的那几类事实,按 skillExcavate 的措辞取。
450
+ *
451
+ * 略去的段落不是「都一样」:够不着/账本护住/挖不动混在里头,只报段数会把诊断整类吞掉。
452
+ */
453
+ export const CLEAR_BLOCK_MARKS = ['够不着', '没动它', '根本挖不动', '紧贴着岩浆', '挖了不掉东西'];
454
+
455
+ /**
456
+ * 世界那一格的完整状态串(属性名排序,与蓝图侧的规范化同一口径)。
457
+ * 回读三分类要它 —— 只比 type 分不出「楼梯朝向被服务端改了」与「根本没完成」。
458
+ */
459
+ export function worldStateAt(bot: Bot, cell: Cell): string | null {
460
+ const b = blockAtCell(bot, cell);
461
+ if (!b) return null;
462
+ const raw = typeof b.getProperties === 'function'
463
+ ? (b.getProperties() as Record<string, unknown>)
464
+ : {};
465
+ const entries = Object.entries(raw)
466
+ .map(([k, v]) => [k, String(v)] as [string, string])
467
+ .sort((l, r) => l[0].localeCompare(r[0]));
468
+ return entries.length === 0
469
+ ? b.name
470
+ : `${b.name}[${entries.map(([k, v]) => `${k}=${v}`).join(',')}]`;
471
+ }
472
+
473
+ /** 服务端按邻接方块计算的连接、形状和 in_wall 属性,放置方无法指定最终值。 */
474
+ export const SELF_COMPUTED_PROPS = new Set(['north', 'south', 'east', 'west', 'up', 'shape', 'in_wall']);
475
+
476
+ /** 回读比对用的状态串:摘掉邻接自算的属性,别的照比 */
477
+ export function readbackState(state: string): string {
478
+ const at = state.indexOf('[');
479
+ if (at === -1) return normalizeBlockName(state);
480
+ const id = normalizeBlockName(state.slice(0, at));
481
+ const kept = state.slice(at + 1, -1).split(',')
482
+ .filter((p) => !SELF_COMPUTED_PROPS.has(p.slice(0, p.indexOf('='))));
483
+ return kept.length === 0 ? id : `${id}[${kept.join(',')}]`;
484
+ }
485
+
486
+ /** 一步落在世界的哪几格:单格报一格,多格报两端 */
487
+ export function stepSpanText(step: BlueprintStep, anchor: PositionXYZ): string {
488
+ const from = toWorld(anchor, step.from);
489
+ const to = toWorld(anchor, step.to);
490
+ const one = from.every((v, i) => v === to[i]);
491
+ const cell = (p: PositionXYZ): string => cellText({ x: p[0], y: p[1], z: p[2] });
492
+ return one ? cell(from) : `${cell(from)}–${cell(to)}`;
493
+ }
494
+
495
+ /** 背包现读成 ItemTally(三分账单的「随身」一栏) */
496
+ export function carriedTally(bot: Bot): ItemTally {
497
+ return Object.fromEntries(invSnapshot(bot));
498
+ }
499
+
500
+ /** 三分账单渲染:缺的排前面,只报前几样 */
501
+ export function blueprintBillText(steps: readonly BlueprintStep[], carried: ItemTally, stored: ItemTally): string {
502
+ const bill = billForSteps(steps, { carried, stored });
503
+ if (bill.lines.length === 0) return '这一段一块都不用放';
504
+ const lines = bill.lines.slice(0, 5).map((l) =>
505
+ `${zhName(l.item)} 要 ${l.need}(随身 ${l.carried}、在箱 ${l.stored}`
506
+ + `${l.missing > 0 ? `、还缺 ${l.missing}` : '、够了'})`);
507
+ const rest = bill.lines.length > 5 ? `;另有 ${bill.lines.length - 5} 样` : '';
508
+ return `${lines.join(';')}${rest}`;
509
+ }
510
+
511
+ /** 「能连着施工到第 k/N 步(到第 y 层)」;层号按那一步所在的层报 */
512
+ export function reachText(steps: readonly BlueprintStep[], reach: number, total: number, base: number): string {
513
+ if (steps.length === 0) return '这一段没有成形步骤';
514
+ if (reach === 0) return '手上的料一步都不够,第一步就得停';
515
+ const last = steps[reach - 1];
516
+ return `手上的料能连着施工到第 ${base + reach}/${total} 步(第 ${last.y} 层)`;
517
+ }
518
+
519
+ export function blueprintCellCount(blueprint: NormalizedBlueprint): number {
520
+ return blueprint.layers.reduce(
521
+ (total, layer) => total + layer.reduce((rows, row) => rows + row.length, 0),
522
+ 0,
523
+ );
524
+ }
525
+
526
+ export function readBlueprintWorld(bot: Bot, site: BlueprintSite, anchor: PositionXYZ): BlueprintDiff {
527
+ return diffBlueprint(
528
+ site.blueprint,
529
+ site.plan,
530
+ anchor,
531
+ (x, y, z) => worldStateAt(bot, { x, y, z }),
532
+ { checkAir: true, sampleLimit: blueprintCellCount(site.blueprint) },
533
+ );
534
+ }
535
+
536
+ export function surveyFromDiff(diff: BlueprintDiff): BlueprintSurvey {
537
+ return {
538
+ at: Date.now(),
539
+ matched: diff.matched,
540
+ missing: diff.missing,
541
+ unknown: diff.unknown,
542
+ wrongBlock: diff.conflictCounts['wrong-block'],
543
+ shouldBeAir: diff.conflictCounts['should-be-air'],
544
+ // should-be-air 的 actual 带着完整属性串,zhName 译不了;先取纯 type 再译
545
+ samples: diff.conflicts.slice(0, 8).map((conflict) =>
546
+ `${cellText({ x: conflict.pos[0], y: conflict.pos[1], z: conflict.pos[2] })} `
547
+ + `${zhName(blockIdOf(conflict.actual).replace('minecraft:', ''))}`
548
+ + `→${zhName(blockIdOf(conflict.expect).replace('minecraft:', ''))}`),
549
+ };
550
+ }
551
+
552
+ /** 冲突格按同一 y/z 上相邻的 x 合成线段,每段不超过 excavate 的一单上限。 */
553
+ export function conflictRuns(conflicts: readonly BlueprintConflict[]): Array<[PositionXYZ, PositionXYZ]> {
554
+ const positions = [...new Map(conflicts.map((entry) => [entry.pos.join(','), entry.pos])).values()]
555
+ .sort((left, right) => left[1] - right[1] || left[2] - right[2] || left[0] - right[0]);
556
+ const runs: Array<[PositionXYZ, PositionXYZ]> = [];
557
+ for (const pos of positions) {
558
+ const last = runs[runs.length - 1];
559
+ if (last && last[0][1] === pos[1] && last[0][2] === pos[2]
560
+ && last[1][0] + 1 === pos[0] && last[1][0] - last[0][0] + 1 < EXCAVATE_CELL_CAP) {
561
+ last[1] = [...pos] as PositionXYZ;
562
+ } else {
563
+ runs.push([[...pos] as PositionXYZ, [...pos] as PositionXYZ]);
564
+ }
565
+ }
566
+ return runs;
567
+ }
568
+
569
+ /** 清理冲突格,并将每段 excavate 的回执写入 notes。 */
570
+ export async function clearBlueprintConflicts(
571
+ bot: Bot,
572
+ conflicts: readonly BlueprintConflict[],
573
+ ctx: SkillContext,
574
+ notes: string[],
575
+ ): Promise<void> {
576
+ const liquid = conflicts.filter((entry) => LIQUIDS.has(blockIdOf(entry.actual).replace('minecraft:', '')));
577
+ if (liquid.length > 0) {
578
+ throw new SkillBlocked(
579
+ `清场范围里有 ${liquid.length} 格液体,不能当普通方块挖掉`,
580
+ liquid.slice(0, 3).map((entry) =>
581
+ `${cellText({ x: entry.pos[0], y: entry.pos[1], z: entry.pos[2] })} 是`
582
+ + zhName(entry.actual.replace('minecraft:', ''))),
583
+ );
584
+ }
585
+ for (const [from, to] of conflictRuns(conflicts)) {
586
+ notes.push(await skillExcavate(bot, {
587
+ skill: 'excavate',
588
+ shape: 'box',
589
+ fill: 'solid',
590
+ anchors: [[...from], [...to]],
591
+ }, {
592
+ ...ctx,
593
+ progress: undefined,
594
+ batch: undefined,
595
+ noLight: true,
596
+ }));
597
+ }
598
+ }
599
+
600
+ export function stepWorldCells(step: BlueprintStep, anchor: PositionXYZ): PositionXYZ[] {
601
+ const cells: PositionXYZ[] = [];
602
+ for (let y = step.from[1]; y <= step.to[1]; y++) {
603
+ for (let z = step.from[2]; z <= step.to[2]; z++) {
604
+ for (let x = step.from[0]; x <= step.to[0]; x++) cells.push(toWorld(anchor, [x, y, z]));
605
+ }
606
+ }
607
+ return cells;
608
+ }
609
+
610
+ /**
611
+ * 整张图完工时把它占的格落进成果登记。只登记世界里读得到、且不是空气的那些 ——
612
+ * 图里本来就该空着的格(门洞、屋内空间)不是成果。
613
+ */
614
+ export function noteBlueprintWork(bot: Bot, ctx: SkillContext, site: BlueprintSite, anchor: PositionXYZ): void {
615
+ if (!ctx.works) return;
616
+ const cells: Array<{ x: number; y: number; z: number; kind: 'blueprint'; block: string; site: string }> = [];
617
+ for (const step of site.plan.steps) {
618
+ for (const pos of stepWorldCells(step, anchor)) {
619
+ const b = blockAtCell(bot, { x: pos[0], y: pos[1], z: pos[2] });
620
+ if (!b || AIR_NAMES.has(b.name)) continue;
621
+ cells.push({ x: pos[0], y: pos[1], z: pos[2], kind: 'blueprint', block: b.name, site: site.key });
622
+ }
623
+ }
624
+ ctx.works.noteMany(dimensionOf(bot), cells);
625
+ }
626
+
627
+ export function blueprintCellDone(bot: Bot, step: BlueprintStep, pos: PositionXYZ): boolean {
628
+ const actual = worldStateAt(bot, { x: pos[0], y: pos[1], z: pos[2] });
629
+ return actual !== null && blueprintStepStateMatches(step, actual);
630
+ }
631
+
632
+ export function verifyBlueprintStepChecks(
633
+ bot: Bot,
634
+ step: BlueprintStep,
635
+ anchor: PositionXYZ,
636
+ checks: readonly BlueprintCheckCell[],
637
+ ): void {
638
+ for (const check of checks) {
639
+ if (check.mainStep !== step.index) continue;
640
+ const pos = toWorld(anchor, check.pos);
641
+ const actual = worldStateAt(bot, { x: pos[0], y: pos[1], z: pos[2] });
642
+ if (actual !== null && blockIdOf(actual) === blockIdOf(check.state)) continue;
643
+ throw new SkillBlocked(
644
+ `${cellText({ x: pos[0], y: pos[1], z: pos[2] })} 的从部件该是 ${blockIdOf(check.state)},`
645
+ + `回读是 ${actual ?? '区块没加载'}`,
646
+ );
647
+ }
648
+ }
649
+
650
+ export async function runBlueprintStep(
651
+ bot: Bot,
652
+ step: BlueprintStep,
653
+ anchor: PositionXYZ,
654
+ ctx: SkillContext,
655
+ ): Promise<string | null> {
656
+ const innerCtx: SkillContext = {
657
+ ...ctx,
658
+ progress: undefined,
659
+ batch: undefined,
660
+ };
661
+ if (step.method.kind === 'place') {
662
+ let gap: string | null = null;
663
+ await skillBuild(bot, stepToBuildCall(step, anchor) as PlaceCall, {
664
+ ...innerCtx,
665
+ partial: (why) => { gap = why; },
666
+ });
667
+ if (gap !== null) return gap;
668
+ const postUse = step.method.postUse;
669
+ if (!postUse) return null;
670
+ for (const pos of stepWorldCells(step, anchor)) {
671
+ for (let attempt = 0; attempt < postUse.maxUses && !blueprintCellDone(bot, step, pos); attempt++) {
672
+ await skillUse(bot, { skill: 'use', at: [...pos] }, innerCtx);
673
+ }
674
+ if (!blueprintCellDone(bot, step, pos)) {
675
+ throw new SkillBlocked(
676
+ `${cellText({ x: pos[0], y: pos[1], z: pos[2] })} 的 ${postUse.property}`
677
+ + `没能调到 ${postUse.value}`,
678
+ );
679
+ }
680
+ }
681
+ return null;
682
+ }
683
+
684
+ for (const pos of stepWorldCells(step, anchor)) {
685
+ if (blueprintCellDone(bot, step, pos)) continue;
686
+ if (step.method.baseItem) {
687
+ // 已经是任何一样可转换基材就不必补:锄头对草方块与泥土产出的耕地完全一样
688
+ const current = blockAtCell(bot, { x: pos[0], y: pos[1], z: pos[2] })?.name ?? null;
689
+ if (current === null || !step.method.baseItem.includes(current)) {
690
+ await skillBuild(bot, {
691
+ skill: 'build',
692
+ material: step.method.baseItem[0],
693
+ anchors: [[...pos]],
694
+ }, innerCtx);
695
+ }
696
+ }
697
+ const target: PositionXYZ = step.method.target === 'below'
698
+ ? [pos[0], pos[1] - 1, pos[2]]
699
+ : pos;
700
+ await skillUse(bot, { skill: 'use', item: step.method.item, at: [...target] }, innerCtx);
701
+ if (!blueprintCellDone(bot, step, pos)) {
702
+ throw new SkillBlocked(
703
+ `${cellText({ x: pos[0], y: pos[1], z: pos[2] })} 回读没有变成 ${blockIdOf(step.state)}`,
704
+ );
705
+ }
706
+ }
707
+ return null;
708
+ }
709
+
710
+ /**
711
+ * collect/pickup/take 仅在活跃蓝图缺口减小或可多施工几步时附收益说明。
712
+ * 活跃图为目标点名的图或唯一已装载图;只报读数,不给行动建议。
713
+ */
714
+ export async function withBlueprintGain(
715
+ bot: Bot,
716
+ ctx: SkillContext,
717
+ run: () => Promise<string>,
718
+ ): Promise<string> {
719
+ const desk = ctx.blueprints?.();
720
+ const key = desk?.activeKey() ?? null;
721
+ const site = key !== null && desk ? desk.get(key) : null;
722
+ if (!desk || !site) return run();
723
+ const stored = desk.stored();
724
+ const rest = site.plan.steps.slice(site.cursor);
725
+ if (rest.length === 0) return run();
726
+ const before = billForSteps(rest, { carried: carriedTally(bot), stored });
727
+ const text = await run();
728
+ const after = billForSteps(rest, { carried: carriedTally(bot), stored });
729
+ const bits: string[] = [];
730
+ for (const line of before.lines) {
731
+ if (line.missing <= 0 || bits.length >= 2) continue;
732
+ const now = after.lines.find((l) => l.item === line.item);
733
+ if (!now || now.missing >= line.missing) continue;
734
+ bits.push(`还缺${zhName(line.item)} ${line.missing}→${now.missing}`);
735
+ }
736
+ const gained = after.reachableSteps - before.reachableSteps;
737
+ if (gained > 0) {
738
+ const last = rest[after.reachableSteps - 1];
739
+ bits.push(`够再往前施工 ${gained} 步(到第 ${last.y} 层)了`);
740
+ }
741
+ return bits.length === 0 ? text : `${text}(${site.key} ${bits.join(';')})`;
742
+ }
743
+
744
+ /**
745
+ * 按已装载的蓝图盖。
746
+ *
747
+ * 每一步都是一条**标准 build 调用**(stepToBuildCall 产出的 box+solid),交给
748
+ * `skillBuild` 原样执行 —— 放置、贴面挑选、垫脚、「本来就是这个方块」这些口径
749
+ * 一份都不重写。受理刻的重生锚闸与重力闸在 `submit` 那一层按整张图的落点算过。
750
+ */
751
+ /**
752
+ * 收工回收本趟垫入工地体积的方块,工地外垫脚保留作路。
753
+ * 台账名称与当前方块不符时不动;先挖远处,脚下支撑最后处理并先移出工地。
754
+ */
755
+ export async function reclaimSiteScaffold(
756
+ bot: Bot,
757
+ ctx: SkillContext,
758
+ box: { min: PositionXYZ; max: PositionXYZ },
759
+ /** 开工前台账里已有的那些条目(按对象身份) */
760
+ before: ReadonlySet<object>,
761
+ ): Promise<string> {
762
+ const seen = new Set<string>();
763
+ const targets = placedLedgerOf(bot).filter((p) => {
764
+ if (before.has(p) || !inBox(box, p) || ctx.intended?.has(cellKeyOf(p))) return false;
765
+ const key = cellKeyOf(p);
766
+ if (seen.has(key)) return false;
767
+ seen.add(key);
768
+ return true;
769
+ });
770
+ if (targets.length === 0) return '';
771
+ const me = bot.entity.position;
772
+ targets.sort((a, b) => Math.hypot(b.x - me.x, b.y - me.y, b.z - me.z)
773
+ - Math.hypot(a.x - me.x, a.y - me.y, a.z - me.z));
774
+
775
+ /** 台账点名的那一块还在原地才挖 */
776
+ const digOne = async (p: { name: string; x: number; y: number; z: number }): Promise<boolean> => {
777
+ let b = blockAtCell(bot, p);
778
+ if (!b || b.name !== p.name) return false;
779
+ if (!bot.canDigBlock(b)) {
780
+ try {
781
+ await gotoGoal(bot, new goals.GoalNear(p.x, p.y, p.z, 2), ctx);
782
+ } catch (err) {
783
+ if (err instanceof Aborted) throw err;
784
+ return false;
785
+ }
786
+ b = blockAtCell(bot, p);
787
+ if (!b || b.name !== p.name || !bot.canDigBlock(b)) return false;
788
+ }
789
+ await equipToolFor(bot, b, ctx);
790
+ await digBlock(bot, b, ctx);
791
+ return true;
792
+ };
793
+ /** 人正站在这一块上吗(它是脚下那一格) */
794
+ const underfoot = (p: { x: number; y: number; z: number }): boolean => {
795
+ const feet = feetOf(bot);
796
+ return p.x === feet.x && p.z === feet.z && p.y === feet.y - 1;
797
+ };
798
+
799
+ let dug = 0;
800
+ let left: typeof targets[number] | null = null;
801
+ for (const p of targets) {
802
+ checkAbort(ctx);
803
+ if (underfoot(p)) { left = p; continue; }
804
+ if (await digOne(p)) dug++;
805
+ }
806
+ if (left) {
807
+ if (underfoot(left)) {
808
+ // 挪出工地再挖:从最近的一面往外三格
809
+ const feet = feetOf(bot);
810
+ const out = [
811
+ { d: feet.x - box.min[0], c: { x: box.min[0] - 3, y: feet.y, z: feet.z } },
812
+ { d: box.max[0] - feet.x, c: { x: box.max[0] + 3, y: feet.y, z: feet.z } },
813
+ { d: feet.z - box.min[2], c: { x: feet.x, y: feet.y, z: box.min[2] - 3 } },
814
+ { d: box.max[2] - feet.z, c: { x: feet.x, y: feet.y, z: box.max[2] + 3 } },
815
+ ].sort((a, b) => a.d - b.d)[0].c;
816
+ try {
817
+ await gotoGoal(bot, new goals.GoalNear(out.x, out.y, out.z, 2), ctx);
818
+ } catch (err) {
819
+ if (err instanceof Aborted) throw err;
820
+ }
821
+ }
822
+ if (!underfoot(left) && await digOne(left)) dug++;
823
+ }
824
+ if (dug > 0) await sweepDrops(bot, ctx);
825
+ return dug === 0 ? '' : `顺手清掉了工地里的垫脚 ${dug} 块。`;
826
+ }
827
+
828
+ export async function skillBuildBlueprint(
829
+ bot: Bot,
830
+ call: BlueprintCall,
831
+ ctx: SkillContext,
832
+ ): Promise<string> {
833
+ const desk = ctx.blueprints?.();
834
+ const site = desk?.get(call.blueprint) ?? null;
835
+ if (!desk || !site) {
836
+ throw new SkillBlocked(
837
+ `蓝图「${call.blueprint}」这边没装载,施工不了`,
838
+ [
839
+ '笔记里存过设计要求的话,mc_blueprint 的 design 重新出一张图;',
840
+ '手上有现成数据就 mc_blueprint 的 save 交回来;',
841
+ `装载着哪几份看 mc_blueprint{}(现在:${desk?.keys().join('、') || '一份都没有'})`,
842
+ ],
843
+ );
844
+ }
845
+ const seat = call.at ? resolveAt(bot, call.at) : null;
846
+ const anchor: PositionXYZ | null = seat
847
+ ? [seat.x, seat.y, seat.z]
848
+ : site.anchor;
849
+ if (!anchor) {
850
+ throw new SkillBlocked(
851
+ `第一次施工「${site.key}」要给 at:蓝图 [0,0,0](最低层、最西、最北那一格)落在世界的哪一格`,
852
+ [`这张图 ${site.blueprint.size_xyz.join('×')},往东 ${site.blueprint.size_xyz[0]} 格、`
853
+ + `往上 ${site.blueprint.size_xyz[1]} 格、往南 ${site.blueprint.size_xyz[2]} 格铺开`],
854
+ );
855
+ }
856
+ const moved = site.anchor !== null && site.anchor.some((v, i) => v !== anchor[i]);
857
+ const started = site.startedAt === undefined ? site.anchor !== null : site.startedAt !== null;
858
+ const firstStart = moved || !started;
859
+ const needsSurvey = site.blueprint.site_mode === 'retrofit'
860
+ && (moved || site.anchor === null || (!started && !site.survey));
861
+ const total = site.plan.steps.length;
862
+ const limit = call.stopAfter === undefined
863
+ ? total
864
+ : stepCountThroughLayer(site.plan.steps, call.stopAfter);
865
+ let diff = readBlueprintWorld(bot, site, anchor);
866
+ const head = `「${site.key}」${site.name ? `「${site.name}」` : ''}`
867
+ + `(${site.blueprint.size_xyz.join('×')},共 ${total} 步 / ${site.plan.placeCells} 格,`
868
+ + `锚点 ${cellText({ x: anchor[0], y: anchor[1], z: anchor[2] })})`;
869
+ const unknownNote = diff.unknown > 0
870
+ ? `;${diff.unknown} 格区块没加载,读不到——这几格既没算已建也没算还缺`
871
+ : '';
872
+ const conflictNote = diff.conflicts.length > 0
873
+ ? `冲突 ${diff.conflictCounts['wrong-block'] + diff.conflictCounts['should-be-air']} 格`
874
+ + `(${diff.conflicts.slice(0, 3).map((c) =>
875
+ `${cellText({ x: c.pos[0], y: c.pos[1], z: c.pos[2] })} 现在是${zhName(c.actual.replace('minecraft:', ''))}`
876
+ + `${c.kind === 'should-be-air' ? ',那儿该空着' : `,该是${zhName(c.expect.replace('minecraft:', ''))}`}`)
877
+ .join('、')})`
878
+ : '没有冲突格';
879
+
880
+ // ── 试算:不动工,只把账摊开 ────────────────────────────────────────────
881
+ if (call.dryRun) {
882
+ const todo = diff.remaining.filter((s) => s.index < limit);
883
+ const carried = carriedTally(bot);
884
+ const stored = desk.stored();
885
+ const bill = billForSteps(todo, { carried, stored });
886
+ const lines = [
887
+ `试算${head}:已经对上 ${diff.matched} 格,还差 ${diff.missing} 格(${todo.length} 步)`
888
+ + `;${conflictNote}${unknownNote}。`,
889
+ `料:${blueprintBillText(todo, carried, stored)}。`,
890
+ `${reachText(todo, bill.reachableSteps, total, total - diff.remaining.length)}`
891
+ + `${bill.reachableStepsWithStored > bill.reachableSteps
892
+ ? `;把箱里那些也取来能到第 ${total - diff.remaining.length + bill.reachableStepsWithStored} 步` : ''}。`,
893
+ call.stopAfter === undefined ? '' : `这一单只施工到第 ${call.stopAfter} 层(前 ${limit} 步)。`,
894
+ site.blueprint.site_mode === 'new'
895
+ ? diff.conflicts.length > 0
896
+ ? `这是新建工地;那 ${diff.conflicts.length} 个冲突格须审阅后带 confirm:true 才会清掉并施工。`
897
+ : '这是新建工地;现场没有冲突格。'
898
+ : needsSurvey
899
+ ? '这是改造工地;第一次真实调用只保存现场探测,不会改方块。'
900
+ : diff.conflicts.length > 0
901
+ ? '这是改造工地;冲突须审阅后带 confirm:true 才会清掉并施工。'
902
+ : '这是改造工地;初探已经完成,现场没有需要确认的冲突。',
903
+ firstStart ? '这个世界里还没正式开工,动工时会把这个锚点记进施工绑定。' : '',
904
+ moved ? '锚点跟上次那次不一样:真下这一单等于换地方重新施工,进度从头算。' : '',
905
+ // 图外现场才决定得了的前提(作物下的耕地、门下的地基):试算是她动工前唯一一次核对的机会
906
+ site.plan.advisories.length > 0
907
+ ? `这几条得靠现场满足(编译期看不见工地):\n${renderBlueprintAdvisories(site.plan.advisories)}`
908
+ : '',
909
+ ].filter(Boolean);
910
+ const map = renderLayerMap(site.blueprint);
911
+ const rows = map.layers.reduce((n, l) => n + l.rows.length + 1, map.legend.length + 1);
912
+ if (rows <= BLUEPRINT_MAP_ROWS) {
913
+ lines.push('逐层图(`.`=空气,行是北→南,列是西→东):');
914
+ lines.push(...map.legend.map((l) => ` ${l.char} = ${l.state}(${l.cells} 格)`));
915
+ for (const layer of map.layers) {
916
+ lines.push(` 第 ${layer.y} 层(${layer.nonAirCells} 格):`);
917
+ lines.push(...layer.rows.map((r) => ` ${r}`));
918
+ }
919
+ } else {
920
+ lines.push(`这张图 ${map.layers.length} 层,逐层图打出来 ${rows} 行,太长了没打。`);
921
+ }
922
+ return `${lines.join('\n')}\n没动工`;
923
+ }
924
+
925
+ // ── 改造初探与清场 ──────────────────────────────────────────────────────
926
+ if (needsSurvey) {
927
+ const survey = surveyFromDiff(diff);
928
+ desk.survey(site.key, anchor, survey);
929
+ const conflicts = survey.wrongBlock + survey.shouldBeAir;
930
+ return [
931
+ `${head}:我完成并保存了初始探测;这一次没有改动任何方块。`,
932
+ `已符合 ${survey.matched} 格,待施工 ${survey.missing} 格,冲突 ${conflicts} 格`
933
+ + `${survey.unknown > 0 ? `,另有 ${survey.unknown} 格区块没加载` : ''}。`,
934
+ survey.samples.length > 0 ? `冲突样本:${survey.samples.join('、')}。` : '',
935
+ conflicts > 0
936
+ ? '先审阅现场;接受清掉这些冲突格就用同一锚点再 build 并带 confirm:true,'
937
+ + '要保留现场就用同一个键重新 design,探测结果会带给修订轮。'
938
+ : '现场没有冲突;用同一锚点再 build 就会正式施工。',
939
+ ].filter(Boolean).join('\n');
940
+ }
941
+
942
+ if (diff.unknown > 0) {
943
+ throw new SkillBlocked(`${head}有 ${diff.unknown} 格区块没加载,不能安全清场或施工`);
944
+ }
945
+ // 回收的账从这里起:清场那一段走路垫进来的也算这一趟的(见 reclaimSiteScaffold)
946
+ const box = siteBox(site, anchor);
947
+ const ledgerBefore = new Set<object>(placedLedgerOf(bot));
948
+ const conflictTotal = diff.conflictCounts['wrong-block'] + diff.conflictCounts['should-be-air'];
949
+ // 所有工地模式均对清除既有冲突格执行确认闸。
950
+ /** 她显式要跳过的冲突格:不清场、不拦,放置阶段自然绕过它们 */
951
+ const skipped = conflictTotal > 0 && call.skipConflicts === true ? [...diff.conflicts] : [];
952
+ if (conflictTotal > 0 && skipped.length === 0 && call.confirm !== true) {
953
+ throw new SkillBlocked(
954
+ `${head}的现场比蓝图多出 ${conflictTotal} 个冲突格;没有自动清掉`,
955
+ [
956
+ ...surveyFromDiff(diff).samples,
957
+ '接受清掉这些格就原调用加 confirm:true;'
958
+ + '只想先把能放的放上就加 skipConflicts:true;要保留它们就同键重新 design',
959
+ ],
960
+ );
961
+ }
962
+
963
+ let cleared = 0;
964
+ if (conflictTotal > 0 && skipped.length === 0) {
965
+ /** 清场每一段自己说了什么;受阻时并进外层文案,不然「没动它」这类原因就没了 */
966
+ const clearNotes: string[] = [];
967
+ const said = (): string[] => {
968
+ const seen = [...new Set(clearNotes)];
969
+ if (seen.length <= CLEAR_NOTE_CAP) return seen;
970
+ const rest = seen.slice(CLEAR_NOTE_CAP);
971
+ const kinds = CLEAR_BLOCK_MARKS.filter((m) => rest.some((n) => n.includes(m))).length;
972
+ return [
973
+ ...seen.slice(0, CLEAR_NOTE_CAP),
974
+ `另外 ${rest.length} 段清场回执略去${kinds > 0 ? `,含 ${kinds} 类没展示的受阻原因` : ''}`,
975
+ ];
976
+ };
977
+ const clear = async (targets: readonly BlueprintConflict[]): Promise<void> => {
978
+ try {
979
+ await clearBlueprintConflicts(bot, targets, ctx, clearNotes);
980
+ } catch (err) {
981
+ if (err instanceof Aborted) throw err;
982
+ throw new SkillBlocked(`蓝图清场停下了:${(err as Error).message}`, said());
983
+ }
984
+ };
985
+ const before = new Set(diff.conflicts.map((c) => c.pos.join(',')));
986
+ await clear(diff.conflicts);
987
+ cleared = conflictTotal;
988
+ diff = readBlueprintWorld(bot, site, anchor);
989
+ let left = diff.conflictCounts['wrong-block'] + diff.conflictCounts['should-be-air'];
990
+ // 可容忍的剩余冲突须全部为清场期间新出现的格,数量不超过 CLEAR_RESIDUE_CAP。
991
+ const fresh = diff.conflicts.every((c) => !before.has(c.pos.join(',')));
992
+ if (left > 0 && left <= CLEAR_RESIDUE_CAP && fresh && diff.unknown === 0) {
993
+ await clear(diff.conflicts);
994
+ cleared += left;
995
+ diff = readBlueprintWorld(bot, site, anchor);
996
+ left = diff.conflictCounts['wrong-block'] + diff.conflictCounts['should-be-air'];
997
+ }
998
+ if (diff.unknown > 0 || left > 0) {
999
+ throw new SkillBlocked(
1000
+ `清场后回读仍有 ${left} 个冲突格、${diff.unknown} 格读不到,没有开始放置`,
1001
+ [...conflictCellLines(diff.conflicts), ...said()],
1002
+ );
1003
+ }
1004
+ }
1005
+
1006
+ /** 她要跳过的那些格:回执点名,别让「跳过了」成为她要自己猜的事 */
1007
+ const skipNote = skipped.length === 0
1008
+ ? ''
1009
+ : `按你说的跳过了 ${skipped.length} 个冲突格,它们原样留着:`
1010
+ + `${conflictCellLines(skipped).join(';')}。`;
1011
+
1012
+ // ── 开工 ────────────────────────────────────────────────────────────────
1013
+ if (firstStart) desk.bind(site.key, anchor);
1014
+ const roadmark = firstStart
1015
+ ? '我已经把开工位置写入施工绑定;现在我要用 mc_map 的 set 给这处工地登记名字和锚点,免得之后忘了在哪。'
1016
+ : '';
1017
+ const done = new Set(diff.doneSteps);
1018
+ const todo = site.plan.steps.filter((s) => s.index < limit && !done.has(s.index));
1019
+ const cursorOf = (): number => {
1020
+ let n = 0;
1021
+ while (done.has(n)) n++;
1022
+ return n;
1023
+ };
1024
+ if (todo.length === 0) {
1025
+ desk.progress(site.key, cursorOf());
1026
+ const whole = limit >= total;
1027
+ return [
1028
+ `${head}:${whole ? '整张图' : `到第 ${call.stopAfter} 层这一段`}已经跟世界对上了,`
1029
+ + `没有要补的格${cleared > 0 ? `;我这次清掉了 ${cleared} 个冲突格,清场完成` : ';我没有改动方块'}。`,
1030
+ skipNote,
1031
+ await reclaimSiteScaffold(bot, ctx, box, ledgerBefore),
1032
+ roadmark,
1033
+ ].filter(Boolean).join('\n');
1034
+ }
1035
+
1036
+ let placed = 0;
1037
+ /** 这一趟做成的是哪几步(索引);回执要点名,不然 placed 与 cursor 两个数读起来自相矛盾 */
1038
+ const placedSteps: number[] = [];
1039
+ /** 提前收工的理由;空串 = 这一趟把 todo 走完了 */
1040
+ let halt = '';
1041
+ /** 没推进的那些步:一步一条,收工时全报出来 */
1042
+ const failures: Array<{ index: number; text: string }> = [];
1043
+ let streak = 0;
1044
+ for (const step of todo) {
1045
+ checkAbort(ctx);
1046
+ let why: string | null = null;
1047
+ try {
1048
+ const gap = await runBlueprintStep(bot, step, anchor, ctx);
1049
+ if (gap !== null) why = `只放上一部分:${gap}`;
1050
+ else verifyBlueprintStepChecks(bot, step, anchor, site.plan.checks);
1051
+ } catch (err) {
1052
+ if (err instanceof Aborted) throw err;
1053
+ why = `停下了:${(err as Error).message}`;
1054
+ }
1055
+ if (why !== null) {
1056
+ // 单步失败不终止整张蓝图,继续尝试后续步骤。
1057
+ failures.push({
1058
+ index: step.index,
1059
+ text: `第 ${step.index + 1}/${total} 步(${zhName(step.item)} ${stepSpanText(step, anchor)})${why}`,
1060
+ });
1061
+ streak++;
1062
+ if (streak >= BLUEPRINT_FAIL_STREAK) { halt = `连着 ${streak} 步没推进,先收工`; break; }
1063
+ continue;
1064
+ }
1065
+ streak = 0;
1066
+ done.add(step.index);
1067
+ placed++;
1068
+ placedSteps.push(step.index);
1069
+ ctx.progress?.(placed, todo.length);
1070
+ }
1071
+ const cursor = cursorOf();
1072
+ // 进度是**机械变化**:写回缓存,但一个字都不催她去改笔记(PWSR 收紧第二条)
1073
+ desk.progress(site.key, cursor);
1074
+
1075
+ // ── 回读验收(完成或阶段停都做) ────────────────────────────────────────
1076
+ const entries: ReadbackEntry[] = [];
1077
+ let capped = false;
1078
+ for (const step of site.plan.steps) {
1079
+ if (!done.has(step.index)) continue;
1080
+ for (let y = step.from[1]; y <= step.to[1] && !capped; y++) {
1081
+ for (let z = step.from[2]; z <= step.to[2] && !capped; z++) {
1082
+ for (let x = step.from[0]; x <= step.to[0]; x++) {
1083
+ if (entries.length >= BLUEPRINT_READBACK_CAP) { capped = true; break; }
1084
+ const pos = toWorld(anchor, [x, y, z]);
1085
+ const state = worldStateAt(bot, { x: pos[0], y: pos[1], z: pos[2] });
1086
+ entries.push({
1087
+ pos,
1088
+ expected: readbackState(step.state),
1089
+ actual: state === null ? null : readbackState(state),
1090
+ });
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ // 从部件(门上半格、床头)不单独出步,但漏了它就是没完成:主部件那一步认了才核对
1096
+ for (const check of site.plan.checks) {
1097
+ if (!done.has(check.mainStep) || entries.length >= BLUEPRINT_READBACK_CAP) continue;
1098
+ const pos = toWorld(anchor, check.pos);
1099
+ const state = worldStateAt(bot, { x: pos[0], y: pos[1], z: pos[2] });
1100
+ entries.push({
1101
+ pos,
1102
+ expected: readbackState(check.state),
1103
+ actual: state === null ? null : readbackState(state),
1104
+ });
1105
+ }
1106
+ const back = summarizeReadback(entries);
1107
+ const drift = Object.entries(back.driftProperties)
1108
+ .sort((l, r) => r[1] - l[1]).slice(0, 2)
1109
+ .map(([what, n]) => `${what}×${n}`).join('、');
1110
+ const failed = back.failedSamples.slice(0, 3).map((e) =>
1111
+ `${cellText({ x: e.pos[0], y: e.pos[1], z: e.pos[2] })} 该是 ${e.expected},现在是 ${e.actual ?? '读不到'}`);
1112
+ const readback = entries.length === 0
1113
+ ? ''
1114
+ : `回读 ${back.exact}/${back.total} 格逐格全同`
1115
+ + `${back.drift > 0 ? `,drift ${back.drift}${drift ? `(${drift})` : ''}` : ''}`
1116
+ + `${back.failed > 0 ? `,失败 ${back.failed}(${failed.join(';')})` : ''}`
1117
+ + `${capped ? `(只回读了前 ${BLUEPRINT_READBACK_CAP} 格)` : ''}。`;
1118
+
1119
+ // 回收放在回读之后:回读量的是施工结果,垫脚不该混进那一笔
1120
+ const reclaimed = await reclaimSiteScaffold(bot, ctx, box, ledgerBefore);
1121
+
1122
+ const progress = blueprintProgress(site.plan.steps, cursor);
1123
+ const stillMissing = blueprintBillText(
1124
+ site.plan.steps.filter((s) => !done.has(s.index)),
1125
+ carriedTally(bot), desk.stored(),
1126
+ );
1127
+ const left = total - cursor;
1128
+ if (failures.length > 0) {
1129
+ ctx.partial?.(`蓝图「${site.key}」还差 ${left} 步没完成(游标 ${cursor}/${total})`);
1130
+ }
1131
+ const failNote = failures.length === 0
1132
+ ? ''
1133
+ : `没推进 ${failures.length} 步:${failures.slice(0, 5).map((f) => f.text).join(';')}`
1134
+ + `${failures.length > 5
1135
+ ? `;另有第 ${failures.slice(5).map((f) => f.index + 1).join('、')} 步同样没推进`
1136
+ : ''}${halt ? `。${halt}` : ''}。`;
1137
+ const stopNote = call.stopAfter !== undefined && failures.length === 0 && cursor >= limit
1138
+ ? `按你说的停在第 ${call.stopAfter} 层(第 ${limit}/${total} 步)`
1139
+ : '';
1140
+ const doneNote = failures.length === 0 && !stopNote && cursor >= total ? '整张图施工完了' : '';
1141
+ // 完工后写入长期成果登记;activeSites 在施工游标完成时结束。
1142
+ if (cursor >= total) noteBlueprintWork(bot, ctx, site, anchor);
1143
+ // placed 计已完成步骤,不要求连续;cursor 是 done 的连续前缀长度,两者分别报告。
1144
+ const placedList = placedSteps.length === 0 ? ''
1145
+ : `(第 ${placedSteps.slice(0, 8).map((i) => i + 1).join('、')} 步`
1146
+ + `${placedSteps.length > 8 ? ` 等 ${placedSteps.length} 步` : ''})`;
1147
+ // 游标停在哪一步、为什么停:第 cursor+1 步没做成,它后面做成的都不计进游标。
1148
+ // 「放上了 4 步而游标只到 3」不是数字打架,是这一句没说出口
1149
+ const gapNote = cursor < limit
1150
+ ? `;游标卡在第 ${cursor + 1} 步没做成上,排在它后面的步就算放上了也不往前推游标`
1151
+ : '';
1152
+ return [
1153
+ `${head}:我这一趟放上了 ${placed} 步${placedList}`
1154
+ + `,游标到 ${cursor}/${total}`
1155
+ + `(${Math.round(progress.ratio * 100)}%${progress.layer.y === null ? '' : `,正在第 ${progress.layer.y} 层`})`
1156
+ + `${gapNote}`
1157
+ + `${doneNote ? `,${doneNote}` : ''}${stopNote ? `,${stopNote}` : ''}。`,
1158
+ failNote,
1159
+ cleared > 0 ? `开工前清掉了 ${cleared} 个冲突格。` : '',
1160
+ skipNote,
1161
+ readback,
1162
+ reclaimed,
1163
+ left > 0 ? `还剩 ${left} 步;料:${stillMissing}。` : '',
1164
+ unknownNote ? `${unknownNote.replace(/^;/, '')}。` : '',
1165
+ `我现在在 ${cellText(feetOf(bot))}`,
1166
+ roadmark,
1167
+ ].filter(Boolean).join('\n');
1168
+ }
1169
+