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.
- package/README.md +7 -14
- package/package.json +1 -3
- package/src/boot.ts +18 -0
- package/src/bot.ts +41 -6
- package/src/core/README.md +4 -6
- package/src/core/blobs.ts +23 -2
- package/src/core/config-schema.ts +1 -1
- package/src/core/config.ts +2 -16
- package/src/core/core.ts +15 -5
- package/src/core/loop.ts +55 -27
- package/src/core/session.ts +3 -2
- package/src/core/timers.ts +8 -6
- package/src/core/types.ts +19 -3
- package/src/core/util.ts +17 -1
- package/src/deploy.ts +4 -1
- package/src/extensions/README.md +6 -4
- package/src/extensions/dry-mount.ts +23 -2
- package/src/extensions/manifest.ts +21 -11
- package/src/extensions.ts +147 -23
- package/src/launcher.ts +23 -9
- package/src/protocol/open-responses/context-log.ts +37 -9
- package/src/providers/README.md +32 -8
- package/src/providers/base.ts +3 -1
- package/src/providers/configuration.ts +2 -1
- package/src/providers/console/hub.ts +270 -0
- package/src/providers/console/settings.ts +8 -18
- package/src/providers/console/types.ts +5 -0
- package/src/providers/hub-api.ts +3 -0
- package/src/providers/llamacpp/config.ts +8 -6
- package/src/providers/llamacpp/console/runtime-panel.ts +5 -1
- package/src/providers/llamacpp/console/server.ts +3 -1
- package/src/providers/llamacpp/index.ts +3 -1
- package/src/providers/llamacpp/native.ts +10 -5
- package/src/providers/llamacpp/strings.ts +6 -6
- package/src/providers/name.ts +8 -0
- package/src/providers/openai-responses-compat/config.ts +13 -0
- package/src/providers/openai-responses-compat/console/client.ts +5 -0
- package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
- package/src/providers/openai-responses-compat/console/server.ts +127 -0
- package/src/providers/openai-responses-compat/index.ts +38 -13
- package/src/providers/openai-responses-compat/native.ts +9 -4
- package/src/providers/openai-responses-compat/strings.ts +62 -1
- package/src/providers/registry.ts +5 -0
- package/src/providers/transport/responses-input.ts +59 -10
- package/src/web/README.md +11 -6
- package/src/web/auth.ts +80 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +5 -4
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +6 -8
- package/src/web/client/console-pages/host.ts +24 -7
- package/src/web/client/core/api.ts +5 -1
- package/src/web/client/core/router.ts +15 -0
- package/src/web/client/features/core/strings.ts +2 -2
- package/src/web/client/features/extensions/index.ts +276 -41
- package/src/web/client/features/extensions/strings.ts +84 -10
- package/src/web/client/features/feature.ts +2 -2
- package/src/web/client/features/live/diagnostics.ts +32 -0
- package/src/web/client/features/live/index.ts +34 -11
- package/src/web/client/features/live/onboarding.ts +4 -1
- package/src/web/client/features/live/protocol.ts +1 -0
- package/src/web/client/features/live/strings.ts +20 -14
- package/src/web/client/features/live/timeline.ts +2 -1
- package/src/web/client/features/providers/detail.ts +262 -0
- package/src/web/client/features/providers/drafts.ts +23 -0
- package/src/web/client/features/providers/index.ts +173 -87
- package/src/web/client/features/providers/strings.ts +44 -17
- package/src/web/client/features/providers/types.ts +15 -0
- package/src/web/client/features/settings/general.ts +13 -0
- package/src/web/client/features/settings/index.ts +1 -0
- package/src/web/client/features/settings/strings.ts +6 -0
- package/src/web/client/features/worlds/index.ts +1 -0
- package/src/web/client/main.ts +4 -0
- package/src/web/client/shell/index.ts +28 -48
- package/src/web/client/shell/strings.ts +0 -22
- package/src/web/client/ui/icons.ts +14 -1
- package/src/web/client/ui/prompt-input.tsx +17 -5
- package/src/web/client/ui/strings.ts +0 -2
- package/src/web/console-pages.ts +1 -1
- package/src/web/diagnostics.ts +133 -0
- package/src/web/public/login.html +67 -0
- package/src/web/public/styles.css +143 -26
- package/src/web/server.ts +233 -19
- package/src/web/shared/client-panel.ts +4 -1
- package/src/web/shared/console-protocol.ts +4 -1
- package/src/worlds/bilibili/README.md +1 -1
- package/src/worlds/bilibili/overlay/server.ts +4 -2
- package/src/worlds/minecraft/ADAPT.md +66 -0
- package/src/worlds/minecraft/README.md +69 -11
- package/src/worlds/minecraft/cell-facts.ts +226 -0
- package/src/worlds/minecraft/chests.ts +5 -0
- package/src/worlds/minecraft/containers.ts +325 -0
- package/src/worlds/minecraft/entity-facts.ts +31 -5
- package/src/worlds/minecraft/executor.ts +283 -10348
- package/src/worlds/minecraft/inventory.ts +268 -0
- package/src/worlds/minecraft/melee.ts +419 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
- package/src/worlds/minecraft/placed-ledger.ts +152 -0
- package/src/worlds/minecraft/placement.ts +1038 -0
- package/src/worlds/minecraft/receipt.ts +340 -0
- package/src/worlds/minecraft/skill-context.ts +358 -0
- package/src/worlds/minecraft/skills-build.ts +1169 -0
- package/src/worlds/minecraft/skills-container.ts +1343 -0
- package/src/worlds/minecraft/skills-craft.ts +333 -0
- package/src/worlds/minecraft/skills-dig.ts +624 -0
- package/src/worlds/minecraft/skills-gather.ts +1230 -0
- package/src/worlds/minecraft/skills-interact.ts +1559 -0
- package/src/worlds/minecraft/tools.ts +331 -0
- package/src/worlds/minecraft/travel.ts +763 -0
- package/src/worlds/minecraft/until.ts +75 -0
- package/src/worlds/qq/normalize.ts +14 -0
- package/src/worlds/qq/world.ts +53 -7
- package/src/worlds/terminal/world.ts +5 -3
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进出容器与工作站:pickup、toss、stow、take、smelt、enchant、brew、transit。
|
|
3
|
+
*
|
|
4
|
+
* 每一次存取都以回读槽位为准;开窗与账本在 containers.ts。
|
|
5
|
+
*/
|
|
6
|
+
import type { Bot } from 'mineflayer';
|
|
7
|
+
import pathfinderPkg from 'mineflayer-pathfinder';
|
|
8
|
+
import { Vec3 } from 'vec3';
|
|
9
|
+
import {
|
|
10
|
+
INVENTORY_SLOTS, awaitInvConfirm, invCount, invCountIn, invGains, invGainsSplit, invSnapshot,
|
|
11
|
+
itemAsked, itemPredOf, lootNote, moveExactSlot, noSuchItem, playerInvIn, type InvItem,
|
|
12
|
+
type InvPred,
|
|
13
|
+
} from './inventory.ts';
|
|
14
|
+
import { isSpawnAnchorBlock } from './policy.ts';
|
|
15
|
+
import { roman, zhDimension, zhEnchant, zhName } from './names.ts';
|
|
16
|
+
import { Aborted, SkillBlocked, SkillNoop, checkAbort, sleep, type SkillContext } from './skill-context.ts';
|
|
17
|
+
import { AIR_NAMES, LIQUIDS, blockAtCell, cellText, dimensionOf, feetOf, resolveAt } from './cell-facts.ts';
|
|
18
|
+
import { droppedStackOf, type ItemStack } from './terrain.ts';
|
|
19
|
+
import { CONTAINER_FIND, FURNACE_KINDS, matchItemName } from './chests.ts';
|
|
20
|
+
import { dropGoal, gotoGoal } from './travel.ts';
|
|
21
|
+
import { type SkillCall } from './skills.ts';
|
|
22
|
+
import { pickMissText, pickTargetOf, pickedText, type PickTarget } from './item-pick.ts';
|
|
23
|
+
import {
|
|
24
|
+
containerStacks, findContainers, furnaceDoneAt, noContainerNearby, openNearbyContainer,
|
|
25
|
+
openWindowGuarded, orderForStow, orderForTake, rememberChest, slotStack, smeltPerItemMs,
|
|
26
|
+
type GenericWindow,
|
|
27
|
+
} from './containers.ts';
|
|
28
|
+
import { contentsText, zhErrorText } from './receipt.ts';
|
|
29
|
+
import { ShowPacer } from './show.ts';
|
|
30
|
+
import { type Anchor, type Cell } from './geometry.ts';
|
|
31
|
+
import { FURNACE_STATION, ensureStation, type Station, type StationAt } from './placement.ts';
|
|
32
|
+
import { readEnchants, readPotionId, type ItemEnchant } from './item-facts.ts';
|
|
33
|
+
import { normalizeDimension } from './escape.ts';
|
|
34
|
+
|
|
35
|
+
const { goals } = pathfinderPkg;
|
|
36
|
+
|
|
37
|
+
/** 这一步净增的东西里哪些是重生锚(床/重生锚的物品形态) */
|
|
38
|
+
export function gainedAnchors(before: Map<string, number>, bot: Bot): string[] {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
for (const [name, n] of invSnapshot(bot)) {
|
|
41
|
+
if (n > (before.get(name) ?? 0) && isSpawnAnchorBlock(name)) out.push(zhName(name));
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 床或重生锚进入、离开背包时,报告物品形态,并提示重生点依赖已放置且有效的床或锚。 */
|
|
47
|
+
export function anchorInHandNote(bot: Bot, ctx: SkillContext, names: string[], verb: string): string {
|
|
48
|
+
if (names.length === 0) return '';
|
|
49
|
+
const anchor = ctx.spawnAnchor?.();
|
|
50
|
+
const where = anchor
|
|
51
|
+
? `(重生点记在${anchor.dimension ? zhDimension(anchor.dimension) : '维度未明'} `
|
|
52
|
+
+ `${cellText({ x: Math.floor(anchor.x), y: Math.floor(anchor.y), z: Math.floor(anchor.z) })})`
|
|
53
|
+
: '(现在已经没有个人重生点了)';
|
|
54
|
+
return ` —— ${verb}的${names.join('、')}是你的重生锚${where},它已经不在原来那一格摆着了,` +
|
|
55
|
+
'重生点随之作废;放回去再睡一次才算数。';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function skillPickup(bot: Bot, ctx: SkillContext, item?: string): Promise<string> {
|
|
59
|
+
const before = invSnapshot(bot);
|
|
60
|
+
// 刚打死的东西掉落物实体还没生成:先等一小会儿再判"附近有没有"
|
|
61
|
+
await sleep(500);
|
|
62
|
+
let walked = 0;
|
|
63
|
+
for (let i = 0; i < 8; i++) {
|
|
64
|
+
checkAbort(ctx);
|
|
65
|
+
const me = bot.entity.position;
|
|
66
|
+
let nearest: { pos: { x: number; y: number; z: number }; d: number } | null = null;
|
|
67
|
+
for (const id of Object.keys(bot.entities)) {
|
|
68
|
+
const e = bot.entities[id];
|
|
69
|
+
if (!e?.position || (e.name !== 'item' && e.name !== 'item_stack')) continue;
|
|
70
|
+
const stack = droppedStackOf(e);
|
|
71
|
+
if (item && (!stack || !matchItemName(item, stack.name))) continue;
|
|
72
|
+
const d = e.position.distanceTo(me);
|
|
73
|
+
if (d <= 24 && (!nearest || d < nearest.d)) nearest = { pos: e.position, d };
|
|
74
|
+
}
|
|
75
|
+
if (!nearest) break;
|
|
76
|
+
await gotoGoal(bot, new goals.GoalNear(nearest.pos.x, nearest.pos.y, nearest.pos.z, 0.5), ctx).catch(() => undefined);
|
|
77
|
+
walked++;
|
|
78
|
+
await sleep(300);
|
|
79
|
+
}
|
|
80
|
+
const anchorNote = anchorInHandNote(bot, ctx, gainedAnchors(before, bot), '捡起来');
|
|
81
|
+
// 指定物品时,验收按同一匹配规则区分目标增量与沿途拾得。
|
|
82
|
+
if (item !== undefined) {
|
|
83
|
+
const { wanted, alongside } = invGainsSplit(before, bot, item);
|
|
84
|
+
const along = alongside.length > 0 ? `;顺手带进包的:${alongside.join('、')}` : '';
|
|
85
|
+
if (wanted.length > 0) return `捡了 ${wanted.join('、')}${along}${anchorNote}`;
|
|
86
|
+
if (alongside.length > 0) {
|
|
87
|
+
return `没捡到${zhName(item)}${along.replace(/^;/, ',')}${anchorNote}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const gains = invGains(before, bot);
|
|
91
|
+
if (gains.length > 0) return `捡了 ${gains.join('、')}${anchorNote}`;
|
|
92
|
+
if (walked > 0) return `走了 ${walked} 处掉落物,一样都没进包`;
|
|
93
|
+
// 地上本来就没有可捡的 = 无事可做,不是没做成(见 SkillNoop)
|
|
94
|
+
throw new SkillNoop(item ? `附近没有${zhName(item)}掉落物,包里也没多出什么` : '附近没有掉落物,包里也没多出什么');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 抛远的落点找多远(格);近端要出得了自己的拾取半径(原版 ~1 格),远端别丢到看不见的地方 */
|
|
98
|
+
export const TOSS_RANGE = { min: 4, max: 8 } as const;
|
|
99
|
+
/** 抛远的仰角(弧度)。mineflayer 的 pitch 是**负值朝上**,抬头 30–45° 取中间偏上的 40° */
|
|
100
|
+
export const TOSS_PITCH = -(40 * Math.PI) / 180;
|
|
101
|
+
/** 找方向时按这个角度间隔扫一圈(度) */
|
|
102
|
+
export const TOSS_YAW_STEP = 30;
|
|
103
|
+
|
|
104
|
+
/** `toss` 带 at 时的瞄准上限:手扔的抛物线大致就到这儿,再远只是把东西丢在半路 */
|
|
105
|
+
export const TOSS_AIM_MAX = 8;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* toss 按当前 yaw/pitch 给物品初速度,须预先转向。
|
|
109
|
+
* 纯读已加载格,在 min..max 范围检查落点及头格均为空气;选最长畅通方向,同距取首。
|
|
110
|
+
* 无合格方向返回 null,调用方就地丢弃;不寻路或修改方块。
|
|
111
|
+
*/
|
|
112
|
+
export function planTossThrow(bot: Bot): { yaw: number; distance: number } | null {
|
|
113
|
+
// 读不了方块、或转不了头(台架的裸 bot)= 没有合格方向可挑,退回就地扔
|
|
114
|
+
if (typeof bot.blockAt !== 'function' || typeof bot.look !== 'function') return null;
|
|
115
|
+
const me = bot.entity.position;
|
|
116
|
+
const eyeY = Math.floor(me.y + 1);
|
|
117
|
+
let best: { yaw: number; distance: number } | null = null;
|
|
118
|
+
for (let deg = 0; deg < 360; deg += TOSS_YAW_STEP) {
|
|
119
|
+
const rad = (deg * Math.PI) / 180;
|
|
120
|
+
// mineflayer 的 yaw:0 = -Z(北),向 -X 增大。这两行与 skillFish 的算法同一套
|
|
121
|
+
const dx = -Math.sin(rad);
|
|
122
|
+
const dz = -Math.cos(rad);
|
|
123
|
+
let reach = 0;
|
|
124
|
+
for (let d = TOSS_RANGE.min; d <= TOSS_RANGE.max; d++) {
|
|
125
|
+
const cell = { x: Math.floor(me.x + dx * d), y: eyeY, z: Math.floor(me.z + dz * d) };
|
|
126
|
+
const at = blockAtCell(bot, cell);
|
|
127
|
+
const above = blockAtCell(bot, { ...cell, y: cell.y + 1 });
|
|
128
|
+
const clear = (b: ReturnType<Bot['blockAt']>): boolean =>
|
|
129
|
+
b !== null && b.boundingBox === 'empty' && !LIQUIDS.has(b.name);
|
|
130
|
+
if (!clear(at) || !clear(above)) break;
|
|
131
|
+
reach = d;
|
|
132
|
+
}
|
|
133
|
+
if (reach > 0 && (!best || reach > best.distance)) best = { yaw: rad, distance: reach };
|
|
134
|
+
}
|
|
135
|
+
return best;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** yaw 弧度 → 八向汉字。只用来在回执里说清「往哪边扔的」 */
|
|
139
|
+
export function yawCompass(yaw: number): string {
|
|
140
|
+
const names = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
|
|
141
|
+
const dx = -Math.sin(yaw);
|
|
142
|
+
const dz = -Math.cos(yaw);
|
|
143
|
+
// 屏幕坐标里 -Z 是北、+X 是东;atan2 取正东为 0 再按 45° 分档
|
|
144
|
+
const deg = ((Math.atan2(dx, -dz) * 180) / Math.PI + 360) % 360;
|
|
145
|
+
return names[Math.round(deg / 45) % 8];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function skillToss(
|
|
149
|
+
bot: Bot,
|
|
150
|
+
call: Extract<SkillCall, { skill: 'toss' }>,
|
|
151
|
+
ctx: SkillContext,
|
|
152
|
+
): Promise<string> {
|
|
153
|
+
const { item, count, pick } = call;
|
|
154
|
+
const pred = itemPredOf(bot, item, pick);
|
|
155
|
+
const have = invCount(bot, pred);
|
|
156
|
+
if (have === 0) throw noSuchItem(bot, item, pick);
|
|
157
|
+
const want = Math.min(count, have);
|
|
158
|
+
let left = want;
|
|
159
|
+
let tossed = 0;
|
|
160
|
+
// 先转向再扔。两种瞄法:
|
|
161
|
+
// · 给了 at —— 朝那一格扔(以物易物把金锭扔到猪灵脚边就是这一形态)
|
|
162
|
+
// · 没给 —— 自己挑一个开阔方向,让抛物线把东西送出自己的拾取半径(见 planTossThrow)
|
|
163
|
+
const aim = call.at === undefined ? null : resolveAt(bot, call.at);
|
|
164
|
+
let where: string;
|
|
165
|
+
if (aim) {
|
|
166
|
+
const me = bot.entity.position;
|
|
167
|
+
const flat = Math.hypot(aim.x + 0.5 - me.x, aim.z + 0.5 - me.z);
|
|
168
|
+
if (flat > TOSS_AIM_MAX) {
|
|
169
|
+
throw new SkillBlocked(
|
|
170
|
+
`${cellText(aim)} 离我 ${flat.toFixed(1)} 格,手扔够不着(最远约 ${TOSS_AIM_MAX} 格);先走近再扔`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (typeof bot.lookAt === 'function') {
|
|
174
|
+
checkAbort(ctx);
|
|
175
|
+
await bot.lookAt(new Vec3(aim.x + 0.5, aim.y + 0.5, aim.z + 0.5), true);
|
|
176
|
+
}
|
|
177
|
+
// 落点不承诺:抛物线的终点是服务端算的,这里只说得出朝哪儿扔的、隔多远
|
|
178
|
+
where = `,朝 ${cellText(aim)} 扔的(隔 ${flat.toFixed(1)} 格);东西按抛物线落在那个方向,不保证正好落在那一格`;
|
|
179
|
+
} else {
|
|
180
|
+
const throwTo = planTossThrow(bot);
|
|
181
|
+
if (throwTo) {
|
|
182
|
+
checkAbort(ctx);
|
|
183
|
+
await bot.look(throwTo.yaw, TOSS_PITCH, true);
|
|
184
|
+
}
|
|
185
|
+
where = throwTo
|
|
186
|
+
? `,朝${yawCompass(throwTo.yaw)}抬头抛出去,那个方向 ${throwTo.distance} 格内是空的`
|
|
187
|
+
: ',周围 4–8 格没找到又空又开阔的方向,就在脚边扔的';
|
|
188
|
+
}
|
|
189
|
+
const picked: PickTarget[] = [];
|
|
190
|
+
for (const it of bot.inventory.items().filter((i) => pred(i.name, i))) {
|
|
191
|
+
if (left <= 0) break;
|
|
192
|
+
const n = Math.min(it.count, left);
|
|
193
|
+
const before = invCount(bot, (name) => name === it.name);
|
|
194
|
+
// 点名的那一件按槽位扔(bot.toss 按类型找槽,同 id 的几件在它眼里没分别)
|
|
195
|
+
if (pick !== undefined && it.count === 1) await bot.tossStack(it as never);
|
|
196
|
+
else await bot.toss(it.type, it.metadata ?? null, n);
|
|
197
|
+
const moved = before - invCount(bot, (name) => name === it.name);
|
|
198
|
+
if (moved <= 0) break;
|
|
199
|
+
tossed += moved;
|
|
200
|
+
left -= moved;
|
|
201
|
+
if (pick !== undefined) picked.push(pickTargetOf(it, bot.registry as never));
|
|
202
|
+
}
|
|
203
|
+
if (tossed === 0) throw new SkillBlocked(`${zhName(item)}没扔出去`);
|
|
204
|
+
const anchor = isSpawnAnchorBlock(item) ? anchorInHandNote(bot, ctx, [zhName(item)], '扔掉') : '';
|
|
205
|
+
// 往哪边扔的照实说;没找到开阔方向时说清是就地扔的(否则她会以为东西在几格外)
|
|
206
|
+
return `扔掉了${picked.length > 0 ? pickedText(picked) : zhName(item)}×${tossed}${where}${anchor}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 一次开窗里存一样东西的账 */
|
|
210
|
+
export interface StowEntry {
|
|
211
|
+
/** 并进来的是这一单里的第几步(0 起);当前这一步是 null */
|
|
212
|
+
stepIndex: number | null;
|
|
213
|
+
item: string;
|
|
214
|
+
count: number;
|
|
215
|
+
/** 挑选词;不写 = 同 id 的几件不分辨 */
|
|
216
|
+
pick?: string;
|
|
217
|
+
pred: InvPred;
|
|
218
|
+
/** 真正点走的那几件各自是什么。只在写了挑选词时记:回执要点名挑中的是哪几件 */
|
|
219
|
+
picked: PickTarget[];
|
|
220
|
+
/** 开窗前那本账(开着窗 bot.inventory 是冻的),关窗后拿它对账 */
|
|
221
|
+
invBefore: number;
|
|
222
|
+
deposited: number;
|
|
223
|
+
/** 点不动的时候服务端/mineflayer 给的原话,翻好了带进回执:不拿猜测顶替 */
|
|
224
|
+
failure: string | null;
|
|
225
|
+
snap: ReturnType<typeof rememberChest>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 一次开窗最多并几步:并太多这扇窗要开很久,反射抢占的窗口也跟着变大 */
|
|
229
|
+
export const STOW_BATCH_MAX = 6;
|
|
230
|
+
|
|
231
|
+
/** 相邻 stow 仅在 orderForStow 仍选同一首选箱子时共用窗口,不改变选箱策略。 */
|
|
232
|
+
export function collectStowBatch(
|
|
233
|
+
bot: Bot,
|
|
234
|
+
ctx: SkillContext,
|
|
235
|
+
found: Parameters<typeof orderForStow>[0],
|
|
236
|
+
target: { x: number; y: number; z: number },
|
|
237
|
+
): Array<{ stepIndex: number; item: string; count: number; pick?: string }> {
|
|
238
|
+
const b = ctx.batch;
|
|
239
|
+
const out: Array<{ stepIndex: number; item: string; count: number; pick?: string }> = [];
|
|
240
|
+
if (!b) return out;
|
|
241
|
+
for (let i = b.index + 1; i < b.steps.length && out.length < STOW_BATCH_MAX - 1; i++) {
|
|
242
|
+
const s = b.steps[i];
|
|
243
|
+
if (s.skill !== 'stow') break;
|
|
244
|
+
// 她自己写了闸门或判据的步不并:并进来就等于替她跳过 needs、替她免掉 expect 裁决。
|
|
245
|
+
// 缺省的因果闸不用管——它拦的是「上游没产出」,而下一行的包里没有正好覆盖那种情况。
|
|
246
|
+
if (s.needs !== undefined || s.expect !== undefined) break;
|
|
247
|
+
// 包里没有这样东西:别并,让它自己跑自己报「包里没有X」
|
|
248
|
+
if (invCount(bot, itemPredOf(bot, s.item, s.pick)) === 0) break;
|
|
249
|
+
const chest = orderForStow(found, ctx, bot, s.item)[0];
|
|
250
|
+
if (!chest || chest.x !== target.x || chest.y !== target.y || chest.z !== target.z) break;
|
|
251
|
+
out.push({ stepIndex: i, item: s.item, count: s.count, ...(s.pick ? { pick: s.pick } : {}) });
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 一样东西的回执;ok=false 的那句在当前步是 SkillBlocked 的理由,并进来的步则不并 */
|
|
257
|
+
export function stowReceipt(
|
|
258
|
+
where: string,
|
|
259
|
+
e: StowEntry,
|
|
260
|
+
conf: Awaited<ReturnType<typeof awaitInvConfirm>> | null,
|
|
261
|
+
/**
|
|
262
|
+
* 这次开窗里同一样东西一共点走了几个。
|
|
263
|
+
*
|
|
264
|
+
* 双记账(点击侧 `deposited` / 库存侧 `conf.moved`)本身是对的:两个数不等正是它
|
|
265
|
+
* 要暴露的 `copyInventory` 回灌差异。坏在措辞——一样东西拆成两堆点走时,两行
|
|
266
|
+
* 「点走 64…少了 70」「点走 6…少了 70」读起来像搬了两次 70 个。
|
|
267
|
+
* 合成一句「本次开窗合计」之后,两个数各自对应的量纲一眼看得出来。
|
|
268
|
+
*/
|
|
269
|
+
windowTotal: number,
|
|
270
|
+
): { ok: boolean; text: string } {
|
|
271
|
+
const name = zhName(e.item);
|
|
272
|
+
// 点名存的时候,回执里那个名字得是**挑中的那几件**;只说 id 等于没回答「存的是哪本」
|
|
273
|
+
const what = e.picked.length > 0 ? pickedText(e.picked) : name;
|
|
274
|
+
const tail = `。箱里现在:${contentsText(e.snap.items)}`;
|
|
275
|
+
if (e.deposited === 0) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
text: `${where}存不进${name}:${e.failure ?? '窗口里一格都没动(箱子满了或对不上)'}${tail}`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const want = Math.min(e.count, e.invBefore);
|
|
282
|
+
const short = e.deposited < want && e.failure ? `;剩下的没存进去:${e.failure}` : '';
|
|
283
|
+
const head = `往${where}存了${what}×${e.deposited}${short}`;
|
|
284
|
+
if (conf === null) {
|
|
285
|
+
return { ok: true, text: `${head},没来得及对账就被打断了,存没存进以箱里为准${tail}` };
|
|
286
|
+
}
|
|
287
|
+
if (conf.status === 'timeout') {
|
|
288
|
+
return {
|
|
289
|
+
ok: true,
|
|
290
|
+
text: `${head},但关窗后包里的账没跟着变(服务端没回灌确认),存没存进以箱里为准${tail}`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (conf.status === 'rolled-back') {
|
|
294
|
+
return { ok: false, text: `往${where}存${name}×${e.deposited}被服务端收回了,包里一个都没少${tail}` };
|
|
295
|
+
}
|
|
296
|
+
if (conf.moved !== windowTotal) {
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
text: `${head};本次开窗合计:点走 ${windowTotal},关窗后包里少了 ${conf.moved}${tail}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return { ok: true, text: `${head}${tail}` };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function skillStow(
|
|
306
|
+
bot: Bot, call: Extract<SkillCall, { skill: 'stow' }>, ctx: SkillContext,
|
|
307
|
+
): Promise<string> {
|
|
308
|
+
const { item, count, pick } = call;
|
|
309
|
+
if (invCount(bot, itemPredOf(bot, item, pick)) === 0) throw noSuchItem(bot, item, pick);
|
|
310
|
+
const found = findContainers(bot, 32);
|
|
311
|
+
if (found.length === 0) throw noContainerNearby(bot, ctx);
|
|
312
|
+
const target = orderForStow(found, ctx, bot, item)[0];
|
|
313
|
+
const where = `(${target.x}, ${target.y}, ${target.z}) 的${zhName(target.name)}`;
|
|
314
|
+
const plan: Array<{ stepIndex: number | null; item: string; count: number; pick?: string }> = [
|
|
315
|
+
{ stepIndex: null, item, count, ...(pick ? { pick } : {}) },
|
|
316
|
+
...collectStowBatch(bot, ctx, found, target),
|
|
317
|
+
];
|
|
318
|
+
const show = new ShowPacer(ctx.showTempo?.() ?? null);
|
|
319
|
+
let chest;
|
|
320
|
+
try {
|
|
321
|
+
await show.openGap();
|
|
322
|
+
chest = await openNearbyContainer(bot, target, ctx);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err instanceof Aborted || err instanceof SkillBlocked) throw err;
|
|
325
|
+
throw new SkillBlocked(`打不开 ${where}: ${zhErrorText((err as Error).message)}`);
|
|
326
|
+
}
|
|
327
|
+
const entries: StowEntry[] = [];
|
|
328
|
+
/** 抢占落在半路:已经点进去的那几样照样要关窗对账、照样要落回执,不能当没发生过 */
|
|
329
|
+
let aborted: Aborted | null = null;
|
|
330
|
+
try {
|
|
331
|
+
await show.beat('open');
|
|
332
|
+
// 进度只能照窗口自己的账读(见 playerInvIn);每轮重取,别拿旧数组接着走
|
|
333
|
+
const view = playerInvIn(bot, chest);
|
|
334
|
+
for (const p of plan) {
|
|
335
|
+
const pred = itemPredOf(bot, p.item, p.pick);
|
|
336
|
+
const e: StowEntry = {
|
|
337
|
+
stepIndex: p.stepIndex, item: p.item, count: p.count, pick: p.pick, pred, picked: [],
|
|
338
|
+
invBefore: invCount(bot, pred), deposited: 0, failure: null,
|
|
339
|
+
snap: { items: [], usedSlots: 0, slots: 27 },
|
|
340
|
+
};
|
|
341
|
+
entries.push(e);
|
|
342
|
+
const want = Math.min(p.count, invCountIn(view, pred));
|
|
343
|
+
try {
|
|
344
|
+
while (e.deposited < want) {
|
|
345
|
+
checkAbort(ctx);
|
|
346
|
+
const it = view.items().find((i) => pred(i.name, i));
|
|
347
|
+
if (!it) break;
|
|
348
|
+
const n = Math.min(it.count, want - e.deposited);
|
|
349
|
+
const before = invCountIn(view, (name) => name === it.name);
|
|
350
|
+
// undefined = 按类型搬(没点名,或这一摞不止一件);数字 = 点名搬进这一格;
|
|
351
|
+
// null = 该点名搬,可箱里没有空格了
|
|
352
|
+
const into = e.pick !== undefined && it.count === 1 ? chest.firstEmptyContainerSlot() : undefined;
|
|
353
|
+
if (into === null) {
|
|
354
|
+
e.failure = '箱子里没有空格了,点名的那件放不进去';
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
if (into === undefined) await chest.deposit(it.type, it.metadata ?? null, n);
|
|
359
|
+
else await moveExactSlot(bot, it.slot, into);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (err instanceof Aborted) throw err;
|
|
362
|
+
e.failure = zhErrorText((err as Error).message);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
const moved = before - invCountIn(view, (name) => name === it.name);
|
|
366
|
+
if (moved <= 0) break;
|
|
367
|
+
e.deposited += moved;
|
|
368
|
+
if (e.pick !== undefined) e.picked.push(pickTargetOf(it, bot.registry as never));
|
|
369
|
+
await show.beat('click');
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (!(err instanceof Aborted)) throw err;
|
|
373
|
+
aborted = err;
|
|
374
|
+
}
|
|
375
|
+
// 每样各存一份关窗时刻的箱内容:三条回执同刻送达,各说各那一步做完时箱里有什么
|
|
376
|
+
e.snap = rememberChest(ctx, bot, target, chest);
|
|
377
|
+
if (aborted) break;
|
|
378
|
+
}
|
|
379
|
+
await show.beat('close');
|
|
380
|
+
} finally {
|
|
381
|
+
chest.close();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// close() 里的 copyInventory() 才把窗口那本账灌回 bot.inventory,这时才对得上。
|
|
385
|
+
// 并窗只并开关窗,不并对账:一样东西一笔 before/after,双记账一格不少。
|
|
386
|
+
const done: Array<{ e: StowEntry; text: string; ok: boolean }> = [];
|
|
387
|
+
for (const e of entries) {
|
|
388
|
+
let conf: Awaited<ReturnType<typeof awaitInvConfirm>> | null = null;
|
|
389
|
+
if (e.deposited > 0) {
|
|
390
|
+
try {
|
|
391
|
+
conf = await awaitInvConfirm(() => invCount(bot, e.pred), e.invBefore, -1, ctx);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (!(err instanceof Aborted)) throw err;
|
|
394
|
+
aborted ??= err; // 对账途中被抢占:回执降级成「没来得及对账」,不假装确认过
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
ctx.diag?.write({
|
|
398
|
+
lane: 'skill', event: e.deposited === 0 ? 'stow-none' : 'stow-done', taskId: ctx.taskId,
|
|
399
|
+
msg: `${where}:窗口里点走${zhName(e.item)} ${e.deposited} 个,关窗后包里少了 ${conf?.moved ?? 0} 个`,
|
|
400
|
+
data: {
|
|
401
|
+
at: target, item: e.item, want: Math.min(e.count, e.invBefore), deposited: e.deposited,
|
|
402
|
+
confirmed: conf?.moved ?? 0, status: conf?.status ?? 'none', failure: e.failure,
|
|
403
|
+
batchedInto: e.stepIndex === null ? null : (ctx.batch?.index ?? null),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
// 同物品的拆堆和并窗数量累计到整个窗口,再与窗口库存变化比较。
|
|
407
|
+
const windowTotal = entries
|
|
408
|
+
.filter((x) => matchItemName(e.item, x.item) || matchItemName(x.item, e.item))
|
|
409
|
+
.reduce((n, x) => n + x.deposited, 0);
|
|
410
|
+
const r = stowReceipt(where, e, conf, windowTotal);
|
|
411
|
+
done.push({ e, ...r });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 并进来的步:成了才登记(登记的那一步不会再跑)。没成的不登记——它自己跑一趟,
|
|
415
|
+
// 自己报自己的理由;它一格都没动过,重跑不会重复扣料。
|
|
416
|
+
const first = done[0];
|
|
417
|
+
for (const d of done.slice(1)) {
|
|
418
|
+
if (!d.ok || d.e.stepIndex === null) continue;
|
|
419
|
+
ctx.batch?.absorb(d.e.stepIndex, `(跟第 ${(ctx.batch.index ?? 0) + 1} 步同一次开窗)${d.text}`);
|
|
420
|
+
}
|
|
421
|
+
if (aborted) throw aborted;
|
|
422
|
+
if (!first.ok) throw new SkillBlocked(first.text);
|
|
423
|
+
// 存进箱子的床同样离开了地面:重生点跟着作废,这一句不能省
|
|
424
|
+
const anchor = isSpawnAnchorBlock(item) ? anchorInHandNote(bot, ctx, [zhName(item)], '存走') : '';
|
|
425
|
+
return `${first.text}${anchor}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* take 的 at 形态:点名哪一格容器。炉子族走 openFurnace(输出 + 没烧完的料 + 剩的
|
|
430
|
+
* 燃料),箱子族走 openContainer(不带 item 就整箱掏空)。提前取不是灾难——拿到几个
|
|
431
|
+
* 算几个,槽里现在有几个照实说。
|
|
432
|
+
*/
|
|
433
|
+
export async function skillTakeAt(
|
|
434
|
+
bot: Bot,
|
|
435
|
+
call: Extract<SkillCall, { skill: 'take' }>,
|
|
436
|
+
ctx: SkillContext,
|
|
437
|
+
): Promise<string> {
|
|
438
|
+
const cell = resolveAt(bot, call.at!);
|
|
439
|
+
await gotoGoal(bot, new goals.GoalNear(cell.x, cell.y, cell.z, 2), ctx);
|
|
440
|
+
checkAbort(ctx);
|
|
441
|
+
const block = blockAtCell(bot, cell);
|
|
442
|
+
if (!block) throw new SkillBlocked(`${cellText(cell)} 所在区块没加载`);
|
|
443
|
+
if (FURNACE_KINDS.has(block.name)) return takeFromFurnace(bot, call, cell, block, ctx);
|
|
444
|
+
if (block.name === 'brewing_stand') return takeFromBrewingStand(bot, call, cell, block, ctx);
|
|
445
|
+
if (CONTAINER_FIND.includes(block.name)) return takeFromChestAt(bot, call, cell, block, ctx);
|
|
446
|
+
throw new SkillBlocked(`${cellText(cell)} 那一格是${zhName(block.name)},不是箱子、炉子或酿造台,take 掏不了它`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 关窗之后等包里的账跟上:多种东西一起取时对着这几样的总数等 */
|
|
450
|
+
export async function awaitGainsConfirm(
|
|
451
|
+
bot: Bot,
|
|
452
|
+
names: readonly string[],
|
|
453
|
+
beforeTotal: number,
|
|
454
|
+
ctx: SkillContext,
|
|
455
|
+
): Promise<'confirmed' | 'timeout' | 'rolled-back'> {
|
|
456
|
+
const total = (): number => names.reduce((s, n) => s + invCount(bot, (x) => x === n), 0);
|
|
457
|
+
const conf = await awaitInvConfirm(total, beforeTotal, 1, ctx);
|
|
458
|
+
return conf.status;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export async function takeFromFurnace(
|
|
462
|
+
bot: Bot,
|
|
463
|
+
call: Extract<SkillCall, { skill: 'take' }>,
|
|
464
|
+
cell: Cell,
|
|
465
|
+
block: NonNullable<ReturnType<Bot['blockAt']>>,
|
|
466
|
+
ctx: SkillContext,
|
|
467
|
+
): Promise<string> {
|
|
468
|
+
const where = `(${cell.x}, ${cell.y}, ${cell.z}) 的${zhName(block.name)}`;
|
|
469
|
+
const show = new ShowPacer(ctx.showTempo?.() ?? null);
|
|
470
|
+
await show.openGap();
|
|
471
|
+
let furnace: Awaited<ReturnType<Bot['openFurnace']>>;
|
|
472
|
+
try {
|
|
473
|
+
furnace = await openWindowGuarded(bot, ctx, () => bot.openFurnace(block));
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (err instanceof Aborted || err instanceof SkillBlocked) throw err;
|
|
476
|
+
throw new SkillBlocked(`打不开 ${where}: ${zhErrorText((err as Error).message)}`);
|
|
477
|
+
}
|
|
478
|
+
const before = invSnapshot(bot);
|
|
479
|
+
const pred: InvPred | null = call.item ? itemPredOf(bot, call.item, call.pick) : null;
|
|
480
|
+
const parts: string[] = [];
|
|
481
|
+
const takenNames = new Set<string>();
|
|
482
|
+
let leftBehind: string | null = null;
|
|
483
|
+
try {
|
|
484
|
+
await show.beat('open');
|
|
485
|
+
const grab = async (
|
|
486
|
+
label: string,
|
|
487
|
+
read: () => { name: string; count: number } | null,
|
|
488
|
+
out: () => Promise<unknown>,
|
|
489
|
+
): Promise<void> => {
|
|
490
|
+
const s = read();
|
|
491
|
+
if (!s) return;
|
|
492
|
+
if (pred && !pred(s.name, s as never)) {
|
|
493
|
+
leftBehind = leftBehind ?? `${zhName(s.name)}×${s.count} 不是要取的,留在炉里`;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
checkAbort(ctx);
|
|
497
|
+
await out().catch(() => undefined);
|
|
498
|
+
if (!read()) {
|
|
499
|
+
parts.push(`${label}${zhName(s.name)}×${s.count}`);
|
|
500
|
+
takenNames.add(s.name);
|
|
501
|
+
await show.beat('click');
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
await grab('输出槽的', () => furnace.outputItem() ?? null, () => furnace.takeOutput());
|
|
505
|
+
await grab('没烧完的', () => furnace.inputItem() ?? null, () => furnace.takeInput());
|
|
506
|
+
await grab('没烧掉的燃料', () => furnace.fuelItem() ?? null, () => furnace.takeFuel());
|
|
507
|
+
// 槽位剩什么重新入账;估时还需确认炉火与进度。
|
|
508
|
+
const state = {
|
|
509
|
+
input: furnace.inputItem() ?? null,
|
|
510
|
+
fuel: furnace.fuelItem() ?? null,
|
|
511
|
+
output: furnace.outputItem() ?? null,
|
|
512
|
+
};
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
ctx.chests?.rememberFurnace(
|
|
515
|
+
dimensionOf(bot), cell, block.name, state, now,
|
|
516
|
+
furnaceDoneAt(furnace, state.input, block.name, now),
|
|
517
|
+
);
|
|
518
|
+
await show.beat('close');
|
|
519
|
+
} finally {
|
|
520
|
+
furnace.close();
|
|
521
|
+
}
|
|
522
|
+
ctx.diag?.write({
|
|
523
|
+
lane: 'skill', event: parts.length === 0 ? 'take-none' : 'take-done', taskId: ctx.taskId,
|
|
524
|
+
msg: `${where}:取走 ${parts.length} 个槽(${parts.join('、') || '空'})`,
|
|
525
|
+
data: { at: cell, from: block.name, parts, item: call.item ?? null },
|
|
526
|
+
});
|
|
527
|
+
if (parts.length === 0) {
|
|
528
|
+
throw new SkillBlocked(
|
|
529
|
+
call.item
|
|
530
|
+
? `${where}三个槽里没有${zhName(call.item)}${leftBehind ? `(${leftBehind})` : ''}`
|
|
531
|
+
: `${where}三个槽都是空的,没东西可取`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
const names = [...takenNames];
|
|
535
|
+
const beforeTotal = names.reduce((s, n) => s + (before.get(n) ?? 0), 0);
|
|
536
|
+
const status = await awaitGainsConfirm(bot, names, beforeTotal, ctx);
|
|
537
|
+
const head = `从${where}取了:${parts.join('、')}${leftBehind ? `;${leftBehind}` : ''}`;
|
|
538
|
+
if (status === 'rolled-back') {
|
|
539
|
+
throw new SkillBlocked(`${head};但被服务端收回了,包里没多`, [], 'server');
|
|
540
|
+
}
|
|
541
|
+
if (status === 'timeout') {
|
|
542
|
+
return `${head};关窗后包里的账还没跟着变(服务端没回灌确认),拿没拿到以包里为准`;
|
|
543
|
+
}
|
|
544
|
+
return `${head};包里多了 ${invGains(before, bot).join('、') || '(没读出增量)'}`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function takeFromChestAt(
|
|
548
|
+
bot: Bot,
|
|
549
|
+
call: Extract<SkillCall, { skill: 'take' }>,
|
|
550
|
+
cell: Cell,
|
|
551
|
+
block: NonNullable<ReturnType<Bot['blockAt']>>,
|
|
552
|
+
ctx: SkillContext,
|
|
553
|
+
): Promise<string> {
|
|
554
|
+
const where = `(${cell.x}, ${cell.y}, ${cell.z}) 的${zhName(block.name)}`;
|
|
555
|
+
const show = new ShowPacer(ctx.showTempo?.() ?? null);
|
|
556
|
+
await show.openGap();
|
|
557
|
+
let chest: Awaited<ReturnType<Bot['openContainer']>>;
|
|
558
|
+
try {
|
|
559
|
+
chest = await openWindowGuarded(bot, ctx, () => bot.openContainer(block));
|
|
560
|
+
} catch (err) {
|
|
561
|
+
if (err instanceof Aborted || err instanceof SkillBlocked) throw err;
|
|
562
|
+
throw new SkillBlocked(`打不开 ${where}: ${zhErrorText((err as Error).message)}`);
|
|
563
|
+
}
|
|
564
|
+
const before = invSnapshot(bot);
|
|
565
|
+
const pred: InvPred = call.item ? itemPredOf(bot, call.item, call.pick) : (): boolean => true;
|
|
566
|
+
const want = call.item ? call.count ?? 1 : Infinity;
|
|
567
|
+
const taken = new Map<string, number>();
|
|
568
|
+
let took = 0;
|
|
569
|
+
let failure: string | null = null;
|
|
570
|
+
let noRoom = false;
|
|
571
|
+
let snap: ReturnType<typeof rememberChest> = { items: [], usedSlots: 0, slots: 27 };
|
|
572
|
+
try {
|
|
573
|
+
await show.beat('open');
|
|
574
|
+
const matches = (typeof chest.containerItems === 'function' ? chest.containerItems() : [])
|
|
575
|
+
.filter((i: InvItem) => pred(i.name, i));
|
|
576
|
+
const view = playerInvIn(bot, chest);
|
|
577
|
+
for (const it of matches) {
|
|
578
|
+
if (took >= want) break;
|
|
579
|
+
checkAbort(ctx);
|
|
580
|
+
const n = Math.min(it.count, want - took);
|
|
581
|
+
const beforeN = invCountIn(view, (name) => name === it.name);
|
|
582
|
+
try {
|
|
583
|
+
await chest.withdraw(it.type, it.metadata ?? null, n);
|
|
584
|
+
} catch (err) {
|
|
585
|
+
if (err instanceof Aborted) throw err;
|
|
586
|
+
if (/inventory is full/i.test((err as Error).message)) noRoom = true;
|
|
587
|
+
failure = zhErrorText((err as Error).message);
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
const moved = invCountIn(view, (name) => name === it.name) - beforeN;
|
|
591
|
+
if (moved <= 0) break;
|
|
592
|
+
took += moved;
|
|
593
|
+
taken.set(it.name, (taken.get(it.name) ?? 0) + moved);
|
|
594
|
+
await show.beat('click');
|
|
595
|
+
}
|
|
596
|
+
snap = rememberChest(ctx, bot, cell, chest);
|
|
597
|
+
await show.beat('close');
|
|
598
|
+
} finally {
|
|
599
|
+
chest.close();
|
|
600
|
+
}
|
|
601
|
+
ctx.diag?.write({
|
|
602
|
+
lane: 'skill', event: took === 0 ? 'take-none' : 'take-done', taskId: ctx.taskId,
|
|
603
|
+
msg: `${where}:窗口里点出 ${took} 个`,
|
|
604
|
+
data: { at: cell, item: call.item ?? null, took, failure, noRoom },
|
|
605
|
+
});
|
|
606
|
+
const inside = `箱里现在:${contentsText(snap.items)}`;
|
|
607
|
+
if (took === 0) {
|
|
608
|
+
if (noRoom) {
|
|
609
|
+
throw new SkillBlocked(`背包 ${INVENTORY_SLOTS} 格全满了,${where}的东西取不出来。${inside}`);
|
|
610
|
+
}
|
|
611
|
+
throw new SkillBlocked(
|
|
612
|
+
call.item
|
|
613
|
+
? `${where}里没取到${zhName(call.item)}${failure ? `(${failure})` : ''}。${inside}`
|
|
614
|
+
: `${where}是空的,没东西可取${failure ? `(${failure})` : ''}`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
const names = [...taken.keys()];
|
|
618
|
+
const beforeTotal = names.reduce((s, n) => s + (before.get(n) ?? 0), 0);
|
|
619
|
+
const status = await awaitGainsConfirm(bot, names, beforeTotal, ctx);
|
|
620
|
+
const what = [...taken].map(([n, c]) => `${zhName(n)}×${c}`).join('、');
|
|
621
|
+
const short = call.item && took < (call.count ?? 1)
|
|
622
|
+
? `(要 ${call.count ?? 1} 个,${noRoom ? '背包满了,没处放' : '箱里就这么多'})`
|
|
623
|
+
: '';
|
|
624
|
+
const head = `从${where}取出${what}${short}${failure && !short ? `(没取完:${failure})` : ''}`;
|
|
625
|
+
if (status === 'rolled-back') {
|
|
626
|
+
throw new SkillBlocked(`${head};但被服务端收回了,包里没多。${inside}`, [], 'server');
|
|
627
|
+
}
|
|
628
|
+
if (status === 'timeout') {
|
|
629
|
+
return `${head};关窗后包里的账还没跟着变(服务端没回灌确认),以包里为准。${inside}`;
|
|
630
|
+
}
|
|
631
|
+
return `${head}。${inside}`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export async function skillTake(bot: Bot, call: Extract<SkillCall, { skill: 'take' }>, ctx: SkillContext): Promise<string> {
|
|
635
|
+
if (call.at) return skillTakeAt(bot, call, ctx);
|
|
636
|
+
const item = call.item!; // parse 保证:没有 at 就一定有 item
|
|
637
|
+
const count = call.count ?? 1;
|
|
638
|
+
const pred = itemPredOf(bot, item, call.pick);
|
|
639
|
+
const found = findContainers(bot, 32);
|
|
640
|
+
if (found.length === 0) throw noContainerNearby(bot, ctx);
|
|
641
|
+
const ordered = orderForTake(found, ctx, bot, item);
|
|
642
|
+
const notes: string[] = [];
|
|
643
|
+
let got = 0;
|
|
644
|
+
let unconfirmed = 0;
|
|
645
|
+
/** 拿不出来是因为包满了:这一条决定结论句的主语,不能让"箱子里没取到"顶上去 */
|
|
646
|
+
let noRoom = false;
|
|
647
|
+
// 巡回取最多开 3 口箱:一单共享一份节拍预算,不按箱翻倍
|
|
648
|
+
const show = new ShowPacer(ctx.showTempo?.() ?? null);
|
|
649
|
+
for (const target of ordered.slice(0, 3)) {
|
|
650
|
+
if (got >= count) break;
|
|
651
|
+
checkAbort(ctx);
|
|
652
|
+
const at = `(${target.x}, ${target.y}, ${target.z})`;
|
|
653
|
+
let chest;
|
|
654
|
+
try {
|
|
655
|
+
await show.openGap();
|
|
656
|
+
chest = await openNearbyContainer(bot, target, ctx);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
if (err instanceof Aborted) throw err;
|
|
659
|
+
notes.push(`${at} 打不开`);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
// 开窗期间 bot.inventory 冻在开窗前那本账,正好是 close() 之后要对的账底
|
|
663
|
+
const invBefore = invCount(bot, pred);
|
|
664
|
+
let took = 0;
|
|
665
|
+
/** 点不动的时候服务端/mineflayer 给的原话:不拿猜测顶替 */
|
|
666
|
+
let failure: string | null = null;
|
|
667
|
+
let empty = false;
|
|
668
|
+
let snap: ReturnType<typeof rememberChest>;
|
|
669
|
+
let beforeOpen: ReturnType<typeof containerStacks>;
|
|
670
|
+
try {
|
|
671
|
+
await show.beat('open');
|
|
672
|
+
beforeOpen = containerStacks(chest, bot.registry as never);
|
|
673
|
+
const matches = (typeof chest.containerItems === 'function' ? chest.containerItems() : [])
|
|
674
|
+
.filter((i: InvItem) => pred(i.name, i));
|
|
675
|
+
if (matches.length === 0) {
|
|
676
|
+
empty = true;
|
|
677
|
+
} else {
|
|
678
|
+
// 进度只能照窗口自己的账读(见 playerInvIn)
|
|
679
|
+
const view = playerInvIn(bot, chest);
|
|
680
|
+
for (const it of matches) {
|
|
681
|
+
if (got + took >= count) break;
|
|
682
|
+
checkAbort(ctx);
|
|
683
|
+
const n = Math.min(it.count, count - got - took);
|
|
684
|
+
const before = invCountIn(view, (name) => name === it.name);
|
|
685
|
+
// 点名的那一件按槽位掏(withdraw 按类型找槽,同 id 的几件对它没分别)
|
|
686
|
+
const into = call.pick !== undefined && it.count === 1 ? chest.firstEmptyInventorySlot() : undefined;
|
|
687
|
+
if (into === null) {
|
|
688
|
+
noRoom = true;
|
|
689
|
+
failure = '背包里没有空格了';
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
if (into === undefined) await chest.withdraw(it.type, it.metadata ?? null, n);
|
|
694
|
+
else await moveExactSlot(bot, it.slot, into);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
if (err instanceof Aborted) throw err;
|
|
697
|
+
if (/inventory is full/i.test((err as Error).message)) noRoom = true;
|
|
698
|
+
failure = zhErrorText((err as Error).message);
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
const moved = invCountIn(view, (name) => name === it.name) - before;
|
|
702
|
+
if (moved <= 0) break;
|
|
703
|
+
took += moved;
|
|
704
|
+
await show.beat('click');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
snap = rememberChest(ctx, bot, target, chest);
|
|
708
|
+
await show.beat('close');
|
|
709
|
+
} finally {
|
|
710
|
+
chest.close();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (empty) {
|
|
714
|
+
// 有同 id 的几件而挑选词一件没中:摆出箱里那几件各自是什么,别只说「没有」
|
|
715
|
+
const same = beforeOpen!.items.filter((i) => matchItemName(item, i.name));
|
|
716
|
+
notes.push(call.pick && same.length > 0
|
|
717
|
+
? pickMissText(`${at} `, item, call.pick, same)
|
|
718
|
+
: `${at} 没有${zhName(item)}`);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
// close() 里的 copyInventory() 才把窗口那本账灌回 bot.inventory
|
|
722
|
+
const conf = took > 0
|
|
723
|
+
? await awaitInvConfirm(() => invCount(bot, pred), invBefore, 1, ctx)
|
|
724
|
+
: null;
|
|
725
|
+
ctx.diag?.write({
|
|
726
|
+
lane: 'skill', event: took === 0 ? 'take-none' : 'take-done', taskId: ctx.taskId,
|
|
727
|
+
msg: `${at}:窗口里点出${zhName(item)} ${took} 个,关窗后包里多了 ${conf?.moved ?? 0} 个`,
|
|
728
|
+
data: {
|
|
729
|
+
at: target, item, took, confirmed: conf?.moved ?? 0,
|
|
730
|
+
status: conf?.status ?? 'none', failure,
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
if (took === 0) {
|
|
734
|
+
notes.push(
|
|
735
|
+
`${at} 有${zhName(item)}但${failure ?? '窗口里一格都没动'}。开箱时:${contentsText(beforeOpen.items)}`,
|
|
736
|
+
);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
got += took;
|
|
740
|
+
const short = failure ? `(没取够:${failure})` : '';
|
|
741
|
+
if (conf!.status === 'confirmed') {
|
|
742
|
+
notes.push(`${at} 取出${zhName(item)}×${took}${short},箱里现在:${contentsText(snap.items)}`);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
unconfirmed += took;
|
|
746
|
+
notes.push(
|
|
747
|
+
conf!.status === 'rolled-back'
|
|
748
|
+
? `${at} 取的${zhName(item)}×${took}被服务端收回了,包里没多。箱里现在:${contentsText(snap.items)}`
|
|
749
|
+
: `${at} 取出${zhName(item)}×${took},但关窗后包里的账还没跟着变(服务端没回灌确认)。箱里现在:${contentsText(snap.items)}`,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
if (got === 0) {
|
|
753
|
+
// 包满时以背包容量为受阻原因,不能表述为箱内没有目标物。
|
|
754
|
+
const lead = noRoom
|
|
755
|
+
? `背包 ${INVENTORY_SLOTS} 格全满了,${itemAsked(item, call.pick)}取不出来`
|
|
756
|
+
: `附近箱子里没取到${itemAsked(item, call.pick)}`;
|
|
757
|
+
throw new SkillBlocked(`${lead}。${notes.join(';')}`);
|
|
758
|
+
}
|
|
759
|
+
// 差多少说多少:`取出了×4` 对一句「要 16 个」是半截事实,她会当成到齐了往下走
|
|
760
|
+
const short = got < count ? `(要 ${count} 个,${noRoom ? '背包满了,没处放' : '附近箱子里就取到这些'})` : '';
|
|
761
|
+
const head = `从箱子取出了${zhName(item)}×${got}${short}`;
|
|
762
|
+
if (unconfirmed > 0) {
|
|
763
|
+
return `${head};其中 ${unconfirmed} 个服务端还没回灌确认。${notes.join(';')}`;
|
|
764
|
+
}
|
|
765
|
+
return `${head}。${notes.join(';')}`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** 输入槽只有一格:同一次只烧一种,包里符合的挑最多的那一摞 */
|
|
769
|
+
export function pickSmeltInput(bot: Bot, item: string) {
|
|
770
|
+
return bot.inventory.items()
|
|
771
|
+
.filter((i) => matchItemName(item, i.name))
|
|
772
|
+
.sort((a, b) => b.count - a.count)[0];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* smelt 下料并点火后结束,不等待世界侧烧炼或宣称实际产量。
|
|
777
|
+
* 回执提供炉位、投入量、预计完成时间和取货方式;expectedDoneAt 到期由账本通知。
|
|
778
|
+
* 未开窗炉子的槽位不持续同步,实际产物须取货时读取。
|
|
779
|
+
*/
|
|
780
|
+
export async function skillSmelt(
|
|
781
|
+
bot: Bot,
|
|
782
|
+
item: string,
|
|
783
|
+
count: number,
|
|
784
|
+
fuelName: string,
|
|
785
|
+
ctx: SkillContext,
|
|
786
|
+
pinAt?: Anchor,
|
|
787
|
+
): Promise<string> {
|
|
788
|
+
const input = pickSmeltInput(bot, item);
|
|
789
|
+
if (!input) throw new SkillBlocked(`包里没有${zhName(item)}`);
|
|
790
|
+
// 要烧的那样东西自己不当燃料:同一摞既进输入槽又进燃料槽,账对不上,
|
|
791
|
+
// 而且一句 smelt log 能把要烧的原木先烧光
|
|
792
|
+
if (matchItemName(fuelName, input.name)) {
|
|
793
|
+
throw new SkillBlocked(`${zhName(input.name)}正是要烧的东西,不能拿它自己当燃料`);
|
|
794
|
+
}
|
|
795
|
+
const fuelItem = bot.inventory.items()
|
|
796
|
+
.filter((i) => i.name !== input.name && matchItemName(fuelName, i.name))
|
|
797
|
+
.sort((a, b) => b.count - a.count)[0];
|
|
798
|
+
if (!fuelItem) throw new SkillBlocked(`包里没有${zhName(fuelName)}`);
|
|
799
|
+
const want = Math.min(count, invCount(bot, (n) => n === input.name));
|
|
800
|
+
// 燃料多塞不亏:没烧掉的那部分收尾时 takeFuel 拿得回来,所以不必算每样烧几秒
|
|
801
|
+
const fuelUse = Math.min(invCount(bot, (n) => n === fuelItem.name), 64);
|
|
802
|
+
|
|
803
|
+
let at: StationAt;
|
|
804
|
+
if (pinAt) {
|
|
805
|
+
// 指定了炉子就用那一座:她点名的决定不再被"就近"覆盖
|
|
806
|
+
const cell = resolveAt(bot, pinAt);
|
|
807
|
+
await gotoGoal(bot, new goals.GoalNear(cell.x, cell.y, cell.z, 2), ctx);
|
|
808
|
+
const block = bot.blockAt(new Vec3(cell.x, cell.y, cell.z));
|
|
809
|
+
if (!block || !FURNACE_KINDS.has(block.name)) {
|
|
810
|
+
throw new SkillBlocked(
|
|
811
|
+
`(${cell.x}, ${cell.y}, ${cell.z}) 那一格是${block ? zhName(block.name) : '没加载的区块'},不是炉子`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
at = {
|
|
815
|
+
x: cell.x, y: cell.y, z: cell.z, name: block.name, block, placed: false,
|
|
816
|
+
note: `用了指定的${zhName(block.name)} (${cell.x}, ${cell.y}, ${cell.z})`,
|
|
817
|
+
};
|
|
818
|
+
} else {
|
|
819
|
+
// 还烧着别的东西的炉子不挑(账本口径:输入槽有别的料且没到点)。同料的炉子照常
|
|
820
|
+
// 复用——往里续料是同一炉的事。被跳过的每一座连着原因进回执
|
|
821
|
+
const now = Date.now();
|
|
822
|
+
const busy = new Map<string, string>();
|
|
823
|
+
for (const r of ctx.chests?.loadedFurnaces(dimensionOf(bot)) ?? []) {
|
|
824
|
+
const f = r.furnace;
|
|
825
|
+
if (!f?.input || f.input.name === input.name) continue;
|
|
826
|
+
if (f.expectedDoneAt !== null && f.expectedDoneAt <= now) continue;
|
|
827
|
+
busy.set(`${r.x},${r.y},${r.z}`, `(${r.x}, ${r.y}, ${r.z}) 那座输入槽还留着${zhName(f.input.name)}`);
|
|
828
|
+
}
|
|
829
|
+
at = await ensureStation(bot, FURNACE_STATION, ctx, {
|
|
830
|
+
skip: (c) => busy.get(`${c.x},${c.y},${c.z}`) ?? null,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
// 用的是哪一座、包里还剩几个,由工作站那一步自己报;自备的不是路上的耗材
|
|
834
|
+
const station = `${at.note};`;
|
|
835
|
+
const atLog = { x: at.x, y: at.y, z: at.z, name: at.name };
|
|
836
|
+
const show = new ShowPacer(ctx.showTempo?.() ?? null);
|
|
837
|
+
await show.openGap();
|
|
838
|
+
let furnace: Awaited<ReturnType<Bot['openFurnace']>>;
|
|
839
|
+
try {
|
|
840
|
+
furnace = await openWindowGuarded(bot, ctx, () => bot.openFurnace(at.block));
|
|
841
|
+
} catch (err) {
|
|
842
|
+
if (err instanceof Aborted || err instanceof SkillBlocked) throw err;
|
|
843
|
+
throw new SkillBlocked(`${station}打不开 (${at.x}, ${at.y}, ${at.z}) 的${zhName(at.name)}: ${zhErrorText((err as Error).message)}`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const where = `(${at.x}, ${at.y}, ${at.z}) 的${zhName(at.name)}`;
|
|
847
|
+
const cell = { x: at.x, y: at.y, z: at.z };
|
|
848
|
+
let staleNote = '';
|
|
849
|
+
/** 正常收尾和中断都将当前窗口实际槽位写入容器账本。 */
|
|
850
|
+
const record = (): {
|
|
851
|
+
input: ItemStack | null; fuel: ItemStack | null; output: ItemStack | null;
|
|
852
|
+
doneAt: number | null; fuelLevel: number; progress: number;
|
|
853
|
+
} => {
|
|
854
|
+
const state = {
|
|
855
|
+
input: furnace.inputItem() ?? null,
|
|
856
|
+
fuel: furnace.fuelItem() ?? null,
|
|
857
|
+
output: furnace.outputItem() ?? null,
|
|
858
|
+
};
|
|
859
|
+
const now = Date.now();
|
|
860
|
+
const doneAt = furnaceDoneAt(furnace, state.input, at.name, now);
|
|
861
|
+
ctx.chests?.rememberFurnace(dimensionOf(bot), cell, at.name, state, now, doneAt);
|
|
862
|
+
return { ...state, doneAt, fuelLevel: furnace.fuel, progress: furnace.progress };
|
|
863
|
+
};
|
|
864
|
+
let loaded: ReturnType<typeof record>;
|
|
865
|
+
try {
|
|
866
|
+
try {
|
|
867
|
+
await show.beat('open');
|
|
868
|
+
// 上一炉的残留:成品先收走,否则新产物挤不进输出槽;还烧着别的就不硬塞
|
|
869
|
+
const stale = furnace.outputItem();
|
|
870
|
+
if (stale) {
|
|
871
|
+
const staleName = stale.name;
|
|
872
|
+
const staleCount = stale.count;
|
|
873
|
+
await furnace.takeOutput().catch(() => undefined);
|
|
874
|
+
const remaining = furnace.outputItem();
|
|
875
|
+
const taken = staleCount - (remaining?.name === staleName ? remaining.count : 0);
|
|
876
|
+
staleNote = taken > 0
|
|
877
|
+
? `先收走了上一炉剩在输出槽的${zhName(staleName)}×${taken};`
|
|
878
|
+
: `上一炉的${zhName(staleName)}×${staleCount}仍在输出槽;`;
|
|
879
|
+
await show.beat('click');
|
|
880
|
+
}
|
|
881
|
+
const leftover = furnace.inputItem();
|
|
882
|
+
if (leftover && leftover.name !== input.name) {
|
|
883
|
+
throw new SkillBlocked(`${where}输入槽还留着${zhName(leftover.name)},先处理已有原料`);
|
|
884
|
+
}
|
|
885
|
+
try {
|
|
886
|
+
await furnace.putFuel(fuelItem.type, fuelItem.metadata ?? null, fuelUse);
|
|
887
|
+
await show.beat('click');
|
|
888
|
+
await furnace.putInput(input.type, input.metadata ?? null, want);
|
|
889
|
+
// 炉火点起来在画面上亮一拍再关窗
|
|
890
|
+
await show.beat('result');
|
|
891
|
+
} catch (err) {
|
|
892
|
+
if (err instanceof Aborted) throw err;
|
|
893
|
+
throw new SkillBlocked(`往${where}里放东西失败: ${zhErrorText((err as Error).message)}`);
|
|
894
|
+
}
|
|
895
|
+
} finally {
|
|
896
|
+
loaded = record();
|
|
897
|
+
}
|
|
898
|
+
} finally {
|
|
899
|
+
furnace.close();
|
|
900
|
+
}
|
|
901
|
+
const perS = smeltPerItemMs(at.name) / 1000;
|
|
902
|
+
const slot = (s: ItemStack | null): string => s ? `${zhName(s.name)}×${s.count}` : '空';
|
|
903
|
+
const slots = `输入槽${slot(loaded.input)},燃料槽${slot(loaded.fuel)},输出槽${slot(loaded.output)}`;
|
|
904
|
+
const burning = loaded.fuelLevel > 0 ? '炉火读数正在燃烧' : '未确认炉火正在燃烧';
|
|
905
|
+
const progress = loaded.progress > 0 ? `烧炼进度读数 ${Math.round(loaded.progress * 100)}%` : '未确认烧炼进度';
|
|
906
|
+
ctx.diag?.write({
|
|
907
|
+
lane: 'craft', event: 'smelt-loaded', taskId: ctx.taskId,
|
|
908
|
+
msg: `${where}:${slots};${burning},${progress}`,
|
|
909
|
+
data: {
|
|
910
|
+
at: atLog, requested: { input: input.name, want, fuel: fuelItem.name, fuelUse },
|
|
911
|
+
...loaded, expectedDoneAt: loaded.doneAt,
|
|
912
|
+
},
|
|
913
|
+
});
|
|
914
|
+
const etaClock = loaded.doneAt !== null && ctx.clock ? `${ctx.clock(loaded.doneAt)} 左右,` : '';
|
|
915
|
+
const estimate = loaded.doneAt === null ? '当前不估完成时间。'
|
|
916
|
+
: `若燃料持续足够,预计 ${etaClock}${Math.max(0, Math.round((loaded.doneAt - Date.now()) / 1000))} 秒后出完,到时提醒查看。`;
|
|
917
|
+
return `${station}在${where}下料后读到:${staleNote}${slots}。${burning},${progress}。` +
|
|
918
|
+
`${zhName(at.name)}烧一件约 ${perS} 秒。${estimate}` +
|
|
919
|
+
`取货:{"skill":"take","at":[${at.x},${at.y},${at.z}],"all":true}`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ── 附魔台 ────────────────────────────────────────────────────────────────────
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* 附魔台周围哪些格算书架。原版判据:八个相邻方向各看一次,那个方向脚下与齐头两格
|
|
926
|
+
* **都得是空气**(中间挡了火把/方块整条就不算),然后数它外面那一圈的书架。
|
|
927
|
+
* 吃满是 15 座,再多不涨。
|
|
928
|
+
*/
|
|
929
|
+
export const BOOKSHELF_RING: ReadonlyArray<readonly [number, number]> = [
|
|
930
|
+
[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1],
|
|
931
|
+
];
|
|
932
|
+
|
|
933
|
+
export function countBookshelves(bot: Bot, cell: Cell): number {
|
|
934
|
+
const shelf = (dx: number, dy: number, dz: number): number =>
|
|
935
|
+
(blockAtCell(bot, { x: cell.x + dx, y: cell.y + dy, z: cell.z + dz })?.name === 'bookshelf' ? 1 : 0);
|
|
936
|
+
const seen = new Set<string>();
|
|
937
|
+
let n = 0;
|
|
938
|
+
const take = (dx: number, dy: number, dz: number): void => {
|
|
939
|
+
const key = `${dx},${dy},${dz}`;
|
|
940
|
+
if (seen.has(key)) return;
|
|
941
|
+
seen.add(key);
|
|
942
|
+
n += shelf(dx, dy, dz);
|
|
943
|
+
};
|
|
944
|
+
for (const [dx, dz] of BOOKSHELF_RING) {
|
|
945
|
+
const gapLow = blockAtCell(bot, { x: cell.x + dx, y: cell.y, z: cell.z + dz });
|
|
946
|
+
const gapHigh = blockAtCell(bot, { x: cell.x + dx, y: cell.y + 1, z: cell.z + dz });
|
|
947
|
+
if (!gapLow || !gapHigh || !AIR_NAMES.has(gapLow.name) || !AIR_NAMES.has(gapHigh.name)) continue;
|
|
948
|
+
for (const dy of [0, 1]) {
|
|
949
|
+
take(dx * 2, dy, dz * 2);
|
|
950
|
+
if (dx !== 0 && dz !== 0) {
|
|
951
|
+
take(dx * 2, dy, dz);
|
|
952
|
+
take(dx, dy, dz * 2);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return n;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** 附魔窗口的三档报价;`level` 是门槛等级,`hint` 是原版下手前显示的那一条附魔 */
|
|
960
|
+
export interface EnchantOffer {
|
|
961
|
+
level: number;
|
|
962
|
+
hint: ItemEnchant | null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export interface EnchantWindow {
|
|
966
|
+
enchantments: Array<{ level: number; expected: { enchant: number; level: number } }>;
|
|
967
|
+
enchant(choice: number): Promise<unknown>;
|
|
968
|
+
putTargetItem(item: unknown): Promise<void>;
|
|
969
|
+
putLapis(item: unknown): Promise<void>;
|
|
970
|
+
targetItem(): { name: string; slot: number } | null;
|
|
971
|
+
close(): void;
|
|
972
|
+
slots: Array<{ name: string; count: number; slot: number } | null>;
|
|
973
|
+
items(): Array<{ name: string; count: number; slot: number }>;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** 三档报价都到齐(原版靠 craft_progress_bar 分条推过来)才读得出;超时就说没读到 */
|
|
977
|
+
export async function awaitOffers(win: EnchantWindow, ctx: SkillContext): Promise<boolean> {
|
|
978
|
+
const deadline = Date.now() + 3_000;
|
|
979
|
+
while (Date.now() < deadline) {
|
|
980
|
+
checkAbort(ctx);
|
|
981
|
+
if (win.enchantments.every((e) => e.level >= 0)) return true;
|
|
982
|
+
await sleep(80);
|
|
983
|
+
}
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
export function offerText(i: number, o: EnchantOffer): string {
|
|
988
|
+
const known = o.hint ? `${zhEnchant(o.hint.name)}${roman(o.hint.level)}、` : '';
|
|
989
|
+
return ` ${i + 1} 档 需 ${o.level} 级 + ${i + 1} 青金石:${known}[未知]`;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export const LAPIS = 'lapis_lazuli';
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* 附魔台:只看三档报价,或按一档下手。
|
|
996
|
+
*
|
|
997
|
+
* 必须自开窗口:`use` 右键附魔台开出来的窗口在 skillUse 里被无条件 closeWindow,
|
|
998
|
+
* 而 rememberWindow 只认容器族与炉子族。这里走 mineflayer 的 openEnchantmentTable,
|
|
999
|
+
* 收尾一律把物品与青金石取回来再关窗。
|
|
1000
|
+
*
|
|
1001
|
+
* **不推荐哪一档**:三档的数字与书架数都是事实,选哪档是她的权衡。
|
|
1002
|
+
*/
|
|
1003
|
+
export async function skillEnchant(
|
|
1004
|
+
bot: Bot,
|
|
1005
|
+
call: Extract<SkillCall, { skill: 'enchant' }>,
|
|
1006
|
+
ctx: SkillContext,
|
|
1007
|
+
): Promise<string> {
|
|
1008
|
+
const cell = resolveAt(bot, call.at);
|
|
1009
|
+
await gotoGoal(bot, new goals.GoalNear(cell.x, cell.y, cell.z, 2), ctx);
|
|
1010
|
+
checkAbort(ctx);
|
|
1011
|
+
const block = blockAtCell(bot, cell);
|
|
1012
|
+
if (!block) throw new SkillBlocked(`${cellText(cell)} 所在区块没加载`);
|
|
1013
|
+
if (block.name !== 'enchanting_table') {
|
|
1014
|
+
throw new SkillBlocked(`${cellText(cell)} 那一格是${zhName(block.name)},不是附魔台`);
|
|
1015
|
+
}
|
|
1016
|
+
const target = bot.inventory.items().find((i) => i.name === call.item);
|
|
1017
|
+
if (!target) throw new SkillBlocked(`包里没有${zhName(call.item)}`);
|
|
1018
|
+
const already = readEnchants(target as never, bot.registry as never);
|
|
1019
|
+
const lapisHave = invCount(bot, (n) => n === LAPIS);
|
|
1020
|
+
const shelves = countBookshelves(bot, cell);
|
|
1021
|
+
const levelBefore = bot.experience?.level ?? 0;
|
|
1022
|
+
const where = `附魔台 (${cell.x}, ${cell.y}, ${cell.z})`;
|
|
1023
|
+
|
|
1024
|
+
const win = await openWindowGuarded(
|
|
1025
|
+
bot, ctx, () => bot.openEnchantmentTable(block) as unknown as Promise<EnchantWindow>,
|
|
1026
|
+
);
|
|
1027
|
+
let offers: EnchantOffer[] = [];
|
|
1028
|
+
let done = '';
|
|
1029
|
+
try {
|
|
1030
|
+
const inWindow = (name: string) => win.items().find((i) => i.name === name);
|
|
1031
|
+
const item = inWindow(call.item);
|
|
1032
|
+
if (!item) throw new SkillBlocked(`包里没有${zhName(call.item)}`);
|
|
1033
|
+
await win.putTargetItem(item);
|
|
1034
|
+
const lapis = lapisHave > 0 ? inWindow(LAPIS) : undefined;
|
|
1035
|
+
if (lapis) await win.putLapis(lapis);
|
|
1036
|
+
if (!(await awaitOffers(win, ctx))) {
|
|
1037
|
+
throw new SkillBlocked(
|
|
1038
|
+
`${where}:放上${zhName(call.item)}${lapisHave > 0 ? `与青金石×${lapisHave}` : '(包里没有青金石)'}后,` +
|
|
1039
|
+
`服务端没给出报价。${already.length > 0 ? '这件东西已经附过魔,附魔台不收' : '等了 3 秒没读到'}`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
offers = win.enchantments.map((e) => ({
|
|
1043
|
+
level: e.level,
|
|
1044
|
+
hint: e.expected.enchant >= 0
|
|
1045
|
+
? {
|
|
1046
|
+
name: (bot.registry as unknown as { enchantments?: Record<number, { name?: string }> })
|
|
1047
|
+
.enchantments?.[e.expected.enchant]?.name ?? `#${e.expected.enchant}`,
|
|
1048
|
+
level: Math.max(1, e.expected.level),
|
|
1049
|
+
}
|
|
1050
|
+
: null,
|
|
1051
|
+
}));
|
|
1052
|
+
if (call.index !== undefined) {
|
|
1053
|
+
const i = call.index - 1;
|
|
1054
|
+
const need = call.index;
|
|
1055
|
+
const blockedBy: string[] = [];
|
|
1056
|
+
if (offers[i].level <= 0) blockedBy.push(`${call.index} 档这会儿没有报价`);
|
|
1057
|
+
if (levelBefore < offers[i].level) blockedBy.push(`要 ${offers[i].level} 级,现在 ${levelBefore} 级`);
|
|
1058
|
+
if (levelBefore < need) blockedBy.push(`还要至少 ${need} 级`);
|
|
1059
|
+
if (lapisHave < need) blockedBy.push(`要 ${need} 个青金石,包里 ${lapisHave} 个`);
|
|
1060
|
+
if (blockedBy.length > 0) {
|
|
1061
|
+
throw new SkillBlocked(`${where}:${call.index} 档下不了手——${blockedBy.join(';')}`);
|
|
1062
|
+
}
|
|
1063
|
+
await win.enchant(i);
|
|
1064
|
+
await sleep(300);
|
|
1065
|
+
const after = win.targetItem();
|
|
1066
|
+
const got = after ? readEnchants(after as never, bot.registry as never) : [];
|
|
1067
|
+
done = `第 ${call.index} 档下手了:${zhName(call.item)} → `
|
|
1068
|
+
+ `${got.length > 0 ? got.map((e) => `${zhEnchant(e.name)}${roman(e.level)}`).join('·') : '读不到附魔'}`;
|
|
1069
|
+
}
|
|
1070
|
+
} finally {
|
|
1071
|
+
// 东西一律取回来:窗口一关服务端会把留在槽里的丢在地上
|
|
1072
|
+
const left = win.targetItem();
|
|
1073
|
+
if (left) await bot.putAway(left.slot).catch(() => undefined);
|
|
1074
|
+
for (const s of [win.slots[1]]) if (s) await bot.putAway(s.slot).catch(() => undefined);
|
|
1075
|
+
win.close();
|
|
1076
|
+
await sleep(200);
|
|
1077
|
+
}
|
|
1078
|
+
const levelAfter = bot.experience?.level ?? 0;
|
|
1079
|
+
const lapisAfter = invCount(bot, (n) => n === LAPIS);
|
|
1080
|
+
const head = `${where}:等级 ${levelBefore} · 青金石 ${lapisHave} 个 · 周围有效书架 ${shelves} 座(吃满 15 座)`;
|
|
1081
|
+
const menu = offers.map((o, i) => offerText(i, o)).join('\n');
|
|
1082
|
+
const rule = '「需 N 级」是门槛;真扣掉的是档位号那么多级与同样多的青金石。'
|
|
1083
|
+
+ '原版下手前每档只显示一条附魔,[未知] 是它没显示的那部分。';
|
|
1084
|
+
if (!done) {
|
|
1085
|
+
const worn = already.length > 0
|
|
1086
|
+
? `\n${zhName(call.item)}身上已经有${already.map((e) => `${zhEnchant(e.name)}${roman(e.level)}`).join('·')}。`
|
|
1087
|
+
: '';
|
|
1088
|
+
return `${head}\n${menu}\n${rule}${worn}\n只看了报价,没下手。`;
|
|
1089
|
+
}
|
|
1090
|
+
return `${head}\n${menu}\n${done};等级 ${levelBefore} → ${levelAfter};青金石 ${lapisHave} → ${lapisAfter}。\n${rule}`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ── 酿造台 ────────────────────────────────────────────────────────────────────
|
|
1094
|
+
|
|
1095
|
+
/** 酿造台的窗口槽位(原版固定):0-2 三个瓶位,3 材料位,4 燃料位 */
|
|
1096
|
+
export const BREW_BOTTLE_SLOTS = [0, 1, 2] as const;
|
|
1097
|
+
export const BREW_INPUT_SLOT = 3;
|
|
1098
|
+
export const BREW_FUEL_SLOT = 4;
|
|
1099
|
+
|
|
1100
|
+
/** 原版一轮酿造 400 刻 = 20 秒,与瓶数无关 */
|
|
1101
|
+
export const BREW_ROUND_MS = 20_000;
|
|
1102
|
+
|
|
1103
|
+
export const BREW_STATION: Station = {
|
|
1104
|
+
kinds: ['brewing_stand'], label: '酿造台', hint: '先 craft 一个酿造台(1 根烈焰棒 + 3 块圆石)',
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
/** 一件药水念成「药水(内容 #N)」;`#N` 是原版药水注册表序号,不是药水都不带这个尾巴 */
|
|
1108
|
+
export function potionText(stack: { name: string; count: number } | null): string {
|
|
1109
|
+
if (!stack) return '空';
|
|
1110
|
+
const id = readPotionId(stack as never);
|
|
1111
|
+
return `${zhName(stack.name)}${id === null ? '' : `(内容 #${id})`}×${stack.count}`;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** 三个瓶位现在各是什么;同内容的合并计数 */
|
|
1115
|
+
export function bottleText(win: GenericWindow, loaded: number): string {
|
|
1116
|
+
if (loaded === 0) return '空';
|
|
1117
|
+
const bits = BREW_BOTTLE_SLOTS
|
|
1118
|
+
.map((s) => win.slots[s] ?? null)
|
|
1119
|
+
.filter((s): s is { name: string; count: number; slot: number } => s !== null)
|
|
1120
|
+
.map((s) => potionText(s));
|
|
1121
|
+
return bits.length > 0 ? bits.join('、') : `${loaded} 瓶`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* 酿造:走到台边下料点火就走,与 smelt 同构(寻址三态 + 异步好了提醒 + take 取货)。
|
|
1126
|
+
*
|
|
1127
|
+
* 三段材料链、红石萤石互斥这些原版规矩不在这里判 —— 判了就是替她决定这一瓶该怎么配。
|
|
1128
|
+
* 台子收不收这一对材料由服务端说了算,回执报下料前后槽里各是什么。
|
|
1129
|
+
*/
|
|
1130
|
+
export async function skillBrew(
|
|
1131
|
+
bot: Bot,
|
|
1132
|
+
call: Extract<SkillCall, { skill: 'brew' }>,
|
|
1133
|
+
ctx: SkillContext,
|
|
1134
|
+
): Promise<string> {
|
|
1135
|
+
const bottleHave = invCount(bot, (n) => n === call.bottle);
|
|
1136
|
+
if (bottleHave === 0) throw new SkillBlocked(`包里没有${zhName(call.bottle)}`);
|
|
1137
|
+
if (invCount(bot, (n) => n === call.input) === 0) throw new SkillBlocked(`包里没有${zhName(call.input)}`);
|
|
1138
|
+
|
|
1139
|
+
let at: StationAt;
|
|
1140
|
+
if (call.at) {
|
|
1141
|
+
const cell = resolveAt(bot, call.at);
|
|
1142
|
+
await gotoGoal(bot, new goals.GoalNear(cell.x, cell.y, cell.z, 2), ctx);
|
|
1143
|
+
const block = blockAtCell(bot, cell);
|
|
1144
|
+
if (!block || block.name !== 'brewing_stand') {
|
|
1145
|
+
throw new SkillBlocked(
|
|
1146
|
+
`${cellText(cell)} 那一格是${block ? zhName(block.name) : '没加载的区块'},不是酿造台`,
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
at = {
|
|
1150
|
+
x: cell.x, y: cell.y, z: cell.z, name: block.name, block, placed: false,
|
|
1151
|
+
note: `用了指定的酿造台 ${cellText(cell)}`,
|
|
1152
|
+
};
|
|
1153
|
+
} else {
|
|
1154
|
+
at = await ensureStation(bot, BREW_STATION, ctx);
|
|
1155
|
+
}
|
|
1156
|
+
const cell = { x: at.x, y: at.y, z: at.z };
|
|
1157
|
+
const where = `酿造台 ${cellText(cell)}`;
|
|
1158
|
+
const show = new ShowPacer(ctx.showTempo?.() ?? null);
|
|
1159
|
+
await show.openGap();
|
|
1160
|
+
let win: GenericWindow;
|
|
1161
|
+
try {
|
|
1162
|
+
win = await openWindowGuarded(bot, ctx, () => bot.openContainer(at.block) as unknown as Promise<GenericWindow>);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
if (err instanceof Aborted || err instanceof SkillBlocked) throw err;
|
|
1165
|
+
throw new SkillBlocked(`${at.note};打不开${where}: ${zhErrorText((err as Error).message)}`);
|
|
1166
|
+
}
|
|
1167
|
+
const want = Math.min(call.count, bottleHave);
|
|
1168
|
+
let loaded = 0;
|
|
1169
|
+
let fuelPut = 0;
|
|
1170
|
+
try {
|
|
1171
|
+
await show.beat('open');
|
|
1172
|
+
const grab = (name: string) => win.items().find((i) => i.name === name);
|
|
1173
|
+
const inSlot = (slot: number) => win.slots[slot] ?? null;
|
|
1174
|
+
// 烧着才酿:燃料槽空了就补一份,已经有燃料就不再塞
|
|
1175
|
+
if (!inSlot(BREW_FUEL_SLOT)) {
|
|
1176
|
+
const fuel = grab(call.fuel);
|
|
1177
|
+
if (fuel) {
|
|
1178
|
+
await bot.moveSlotItem(fuel.slot, BREW_FUEL_SLOT).catch(() => undefined);
|
|
1179
|
+
fuelPut = inSlot(BREW_FUEL_SLOT)?.count ?? 0;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
for (const slot of BREW_BOTTLE_SLOTS.slice(0, want)) {
|
|
1183
|
+
if (inSlot(slot)) { loaded++; continue; }
|
|
1184
|
+
const bottle = grab(call.bottle);
|
|
1185
|
+
if (!bottle) break;
|
|
1186
|
+
await bot.moveSlotItem(bottle.slot, slot).catch(() => undefined);
|
|
1187
|
+
if (inSlot(slot)) loaded++;
|
|
1188
|
+
await show.beat('click');
|
|
1189
|
+
}
|
|
1190
|
+
const ingredient = grab(call.input);
|
|
1191
|
+
if (ingredient && !inSlot(BREW_INPUT_SLOT)) {
|
|
1192
|
+
await bot.moveSlotItem(ingredient.slot, BREW_INPUT_SLOT).catch(() => undefined);
|
|
1193
|
+
await show.beat('result');
|
|
1194
|
+
}
|
|
1195
|
+
if (!inSlot(BREW_INPUT_SLOT)) {
|
|
1196
|
+
throw new SkillBlocked(`${at.note};${where}的材料位没放进${zhName(call.input)},台子不收它`);
|
|
1197
|
+
}
|
|
1198
|
+
if (loaded === 0) throw new SkillBlocked(`${at.note};${where}的三个瓶位一个都没放进${zhName(call.bottle)}`);
|
|
1199
|
+
} finally {
|
|
1200
|
+
const state = {
|
|
1201
|
+
input: slotStack(win, BREW_INPUT_SLOT),
|
|
1202
|
+
fuel: slotStack(win, BREW_FUEL_SLOT),
|
|
1203
|
+
output: slotStack(win, BREW_BOTTLE_SLOTS[0]),
|
|
1204
|
+
};
|
|
1205
|
+
const now = Date.now();
|
|
1206
|
+
ctx.chests?.rememberFurnace(
|
|
1207
|
+
dimensionOf(bot), cell, 'brewing_stand', state, now,
|
|
1208
|
+
state.input && loaded > 0 ? now + BREW_ROUND_MS : null,
|
|
1209
|
+
);
|
|
1210
|
+
win.close();
|
|
1211
|
+
}
|
|
1212
|
+
const fuelNote = fuelPut > 0
|
|
1213
|
+
? `燃料槽放了${zhName(call.fuel)}×${fuelPut}(一份烧 20 轮)`
|
|
1214
|
+
: `燃料槽本来就有${zhName(win.slots[BREW_FUEL_SLOT]?.name ?? call.fuel)}`;
|
|
1215
|
+
const etaClock = ctx.clock ? `${ctx.clock(Date.now() + BREW_ROUND_MS)} 左右` : '20 秒后';
|
|
1216
|
+
ctx.diag?.write({
|
|
1217
|
+
lane: 'craft', event: 'brew-start', taskId: ctx.taskId,
|
|
1218
|
+
msg: `${where}:${loaded} 瓶${call.bottle} + ${call.input}`,
|
|
1219
|
+
data: { at: cell, bottles: loaded, input: call.input, fuel: call.fuel, fuelPut },
|
|
1220
|
+
});
|
|
1221
|
+
return `${at.note};在${where}下了料:瓶位${bottleText(win, loaded)},材料位${zhName(call.input)},${fuelNote}。`
|
|
1222
|
+
+ `一轮约 20 秒(${etaClock}好,有事件提醒);这段时间不用守着。`
|
|
1223
|
+
+ `取货:{"skill":"take","at":[${cell.x},${cell.y},${cell.z}],"all":true}`;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** 酿造台取货:三个瓶位 + 剩下的材料与燃料,一律 shift 回包里 */
|
|
1227
|
+
export async function takeFromBrewingStand(
|
|
1228
|
+
bot: Bot,
|
|
1229
|
+
call: Extract<SkillCall, { skill: 'take' }>,
|
|
1230
|
+
cell: Cell,
|
|
1231
|
+
block: NonNullable<ReturnType<Bot['blockAt']>>,
|
|
1232
|
+
ctx: SkillContext,
|
|
1233
|
+
): Promise<string> {
|
|
1234
|
+
const where = `${cellText(cell)} 的酿造台`;
|
|
1235
|
+
const show = new ShowPacer(ctx.showTempo?.() ?? null);
|
|
1236
|
+
await show.openGap();
|
|
1237
|
+
let win: GenericWindow;
|
|
1238
|
+
try {
|
|
1239
|
+
win = await openWindowGuarded(bot, ctx, () => bot.openContainer(block) as unknown as Promise<GenericWindow>);
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
if (err instanceof Aborted || err instanceof SkillBlocked) throw err;
|
|
1242
|
+
throw new SkillBlocked(`打不开 ${where}: ${zhErrorText((err as Error).message)}`);
|
|
1243
|
+
}
|
|
1244
|
+
const before = invSnapshot(bot);
|
|
1245
|
+
const pred: InvPred | null = call.item ? itemPredOf(bot, call.item, call.pick) : null;
|
|
1246
|
+
const took: string[] = [];
|
|
1247
|
+
let leftBehind: string | null = null;
|
|
1248
|
+
try {
|
|
1249
|
+
await show.beat('open');
|
|
1250
|
+
for (const slot of [...BREW_BOTTLE_SLOTS, BREW_INPUT_SLOT, BREW_FUEL_SLOT]) {
|
|
1251
|
+
const s = win.slots[slot];
|
|
1252
|
+
if (!s) continue;
|
|
1253
|
+
if (pred && !pred(s.name, s as never)) {
|
|
1254
|
+
leftBehind = leftBehind ?? `${zhName(s.name)}×${s.count} 不是要取的,留在台里`;
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
checkAbort(ctx);
|
|
1258
|
+
const label = potionText(s);
|
|
1259
|
+
await bot.putAway(s.slot).catch(() => undefined);
|
|
1260
|
+
if (!win.slots[slot]) {
|
|
1261
|
+
took.push(label);
|
|
1262
|
+
await show.beat('click');
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
const state = {
|
|
1266
|
+
input: slotStack(win, BREW_INPUT_SLOT),
|
|
1267
|
+
fuel: slotStack(win, BREW_FUEL_SLOT),
|
|
1268
|
+
output: slotStack(win, BREW_BOTTLE_SLOTS[0]),
|
|
1269
|
+
};
|
|
1270
|
+
ctx.chests?.rememberFurnace(dimensionOf(bot), cell, 'brewing_stand', state, Date.now(), null);
|
|
1271
|
+
} finally {
|
|
1272
|
+
win.close();
|
|
1273
|
+
}
|
|
1274
|
+
if (took.length === 0) {
|
|
1275
|
+
throw new SkillBlocked(`${where}里没有取到东西${leftBehind ? `(${leftBehind})` : '(五个槽都空着)'}`);
|
|
1276
|
+
}
|
|
1277
|
+
await awaitGainsConfirm(bot, [], 0, ctx).catch(() => undefined);
|
|
1278
|
+
return `从${where}取了 ${took.join('、')}${leftBehind ? `;${leftBehind}` : ''}。${lootNote(before, bot)}`;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* 显式穿门只认当前维度里实际读到的下界传送门方块。寻路负责到门边,最后踏进
|
|
1283
|
+
* 门里的动作由这一步自己完成;维度未改变前绝不把“到了门口”当成完成。
|
|
1284
|
+
*/
|
|
1285
|
+
export async function skillTransit(
|
|
1286
|
+
bot: Bot,
|
|
1287
|
+
call: Extract<SkillCall, { skill: 'transit' }>,
|
|
1288
|
+
ctx: SkillContext,
|
|
1289
|
+
): Promise<string> {
|
|
1290
|
+
const portal = resolveAt(bot, call.at);
|
|
1291
|
+
const block = blockAtCell(bot, portal);
|
|
1292
|
+
if (!block) throw new SkillBlocked(`${cellText(portal)} 所在区块没加载`);
|
|
1293
|
+
if (block.name !== 'nether_portal') {
|
|
1294
|
+
throw new SkillBlocked(`${cellText(portal)} 是${zhName(block.name)},不是下界传送门方块`);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const fromDimension = normalizeDimension(dimensionOf(bot));
|
|
1298
|
+
await gotoGoal(bot, new goals.GoalNear(portal.x, portal.y, portal.z, 1), ctx);
|
|
1299
|
+
checkAbort(ctx);
|
|
1300
|
+
const reread = blockAtCell(bot, portal);
|
|
1301
|
+
if (reread?.name !== 'nether_portal') {
|
|
1302
|
+
throw new SkillBlocked(`走到门边时 ${cellText(portal)} 已经不是下界传送门了`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
dropGoal(bot, 'task', '到门边了,自己走进去', ctx.diag);
|
|
1306
|
+
await bot.lookAt(new Vec3(portal.x + 0.5, portal.y + 0.8, portal.z + 0.5), true);
|
|
1307
|
+
const deadline = Date.now() + 20_000;
|
|
1308
|
+
try {
|
|
1309
|
+
while (normalizeDimension(dimensionOf(bot)) === fromDimension) {
|
|
1310
|
+
checkAbort(ctx);
|
|
1311
|
+
if (Date.now() >= deadline) {
|
|
1312
|
+
throw new SkillBlocked(`已经走进 ${cellText(portal)} 的门里等了 20 秒,维度仍是${zhDimension(fromDimension)}`);
|
|
1313
|
+
}
|
|
1314
|
+
const feet = feetOf(bot);
|
|
1315
|
+
const bodyInPortal = blockAtCell(bot, feet)?.name === 'nether_portal'
|
|
1316
|
+
|| blockAtCell(bot, { x: feet.x, y: feet.y + 1, z: feet.z })?.name === 'nether_portal';
|
|
1317
|
+
bot.setControlState('forward', !bodyInPortal);
|
|
1318
|
+
await sleep(100);
|
|
1319
|
+
}
|
|
1320
|
+
} finally {
|
|
1321
|
+
bot.setControlState('forward', false);
|
|
1322
|
+
bot.setControlState('sprint', false);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const changedAt = Date.now();
|
|
1326
|
+
let last = bot.entity.position.clone();
|
|
1327
|
+
let stableAt = changedAt;
|
|
1328
|
+
while (Date.now() - changedAt < 3_000) {
|
|
1329
|
+
checkAbort(ctx);
|
|
1330
|
+
await sleep(100);
|
|
1331
|
+
const now = bot.entity.position;
|
|
1332
|
+
if (now.distanceTo(last) > 0.1) {
|
|
1333
|
+
last = now.clone();
|
|
1334
|
+
stableAt = Date.now();
|
|
1335
|
+
}
|
|
1336
|
+
if (Date.now() - changedAt >= 600 && Date.now() - stableAt >= 400) break;
|
|
1337
|
+
}
|
|
1338
|
+
const toDimension = normalizeDimension(dimensionOf(bot));
|
|
1339
|
+
const arrived = feetOf(bot);
|
|
1340
|
+
return `穿门成功:${zhDimension(fromDimension)} ${cellText(portal)} → `
|
|
1341
|
+
+ `${zhDimension(toDimension)} ${cellText(arrived)}(两端都由这次维度切换实测)`;
|
|
1342
|
+
}
|
|
1343
|
+
|