modsmith 0.6.2 → 0.7.1

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 (39) hide show
  1. package/AGENTS.md +6 -0
  2. package/README.md +131 -20
  3. package/bin/modsmith.mjs +573 -405
  4. package/docs/conformance/README.md +62 -0
  5. package/docs/conformance/farmers-delight-1.21.1-3.3.6.json +209 -0
  6. package/docs/conformance/farmers-delight-dev-live.json +321 -0
  7. package/docs/conformance/moredelight-26.04.30-1.21.json +47 -0
  8. package/docs/conformance/supplementaries-1.21.1-4.0.2.json +227 -0
  9. package/docs/conformance/twilightforest-1.21.1-4.8.796.json +317 -0
  10. package/package.json +16 -3
  11. package/schema/manifest.schema.json +7 -0
  12. package/sidesheets/farmers-delight-3.3.6.crops.json +42 -42
  13. package/src/block-shapes.mjs +176 -0
  14. package/src/calibrate.mjs +1 -0
  15. package/src/conformance-live.mjs +208 -0
  16. package/src/conformance.mjs +366 -0
  17. package/src/cortico-bridge.mjs +397 -325
  18. package/src/crops.mjs +246 -227
  19. package/src/import-dump.mjs +139 -0
  20. package/src/index.mjs +3 -1
  21. package/src/inspect.mjs +466 -461
  22. package/src/manifest-io.mjs +1 -1
  23. package/src/onboard.mjs +21 -3
  24. package/src/probe.mjs +325 -316
  25. package/src/rcon.mjs +14 -8
  26. package/src/server-fingerprint.mjs +5 -5
  27. package/src/stations.mjs +22 -1
  28. package/src/validate.mjs +231 -223
  29. package/src/verify.mjs +18 -10
  30. package/templates/reference-bot/README.md +22 -0
  31. package/templates/reference-bot/index.mjs +59 -0
  32. package/templates/registry-dump/README.md +48 -0
  33. package/templates/registry-dump/build.gradle +39 -0
  34. package/templates/registry-dump/gradle.properties +2 -0
  35. package/templates/registry-dump/modsmith-dump-0.1.2.jar +0 -0
  36. package/templates/registry-dump/settings.gradle +9 -0
  37. package/templates/registry-dump/src/main/java/dev/modsmith/dump/RegistryDumpMod.java +98 -0
  38. package/templates/registry-dump/src/main/resources/fabric.mod.json +18 -0
  39. package/types/index.d.ts +174 -135
package/src/probe.mjs CHANGED
@@ -1,316 +1,325 @@
1
- /**
2
- * `modsmith probe` —— 实测哪些站点/容器真的能用。
3
- *
4
- * 静态分析只能给出**候选**:名字像容器的 `*_crate` 实测打不开,而名字普通的橱柜能开。
5
- * 所以凡是"这个方块右键会发生什么"的问题,都必须上真服务器问一次。
6
- *
7
- * 做的事(都在一块临时搭的平台上,测完清掉):
8
- * 1. 容器候选逐个右键:记下开没开、开的是哪种菜单、多少槽
9
- * 2. 站点候选逐个右键:GUI 类站点记 inventoryType;不开窗的标出来(那类要走侧车)
10
- *
11
- * 依赖:服务器已装好该 mod、开了 RCON、bot 能离线登录且有 OP(要 forceload/setblock/tp)。
12
- */
13
- import mineflayer from 'mineflayer';
14
- import { Vec3 } from 'vec3';
15
- import { readManifest } from './manifest-io.mjs';
16
- import { installModRegistry, MEASURE_DROPS, installMineflayerFixes, loadModPack, setModsDir } from './cortico-bridge.mjs';
17
- import { applyProbe, stationMatches } from './manifest-probe.mjs';
18
- import { checkServerMods } from './server-fingerprint.mjs';
19
- import { makeRcon } from './rcon.mjs';
20
-
21
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
-
23
- /**
24
- * 右键一个方块,看它开不开窗。
25
- * @returns {{ opened: boolean, type?: string, slots?: number, error?: string }}
26
- */
27
- async function tryOpen(bot, block, ms) {
28
- let win = null;
29
- // 自定义窗口类型会被 installMineflayerFixes 的 emit 守卫记在这里:prismarine-windows
30
- // 不认识 → createWindow 返回 null → 窗口事件不会来。这种"开了但界面未知"必须算开窗。
31
- bot._modsmithUnknownWindow = null;
32
- const onWin = (w) => { win = w; };
33
- bot.once('windowOpen', onWin);
34
- const open = block.name.includes(':') ? bot.openBlock(block) : bot.openContainer(block);
35
- let timedOut = false;
36
- try {
37
- await Promise.race([
38
- open.catch((e) => { throw e; }),
39
- sleep(ms).then(() => { timedOut = true; }),
40
- ]);
41
- } catch (err) {
42
- bot.removeListener('windowOpen', onWin);
43
- await sleep(150);
44
- if (bot._modsmithUnknownWindow) {
45
- return { opened: true, type: bot._modsmithUnknownWindow, slots: null, error: '自定义窗口(界面未知)' };
46
- }
47
- const got = win ?? bot.currentWindow ?? null;
48
- if (got) {
49
- const info = { opened: true, type: got.type, slots: got.slots?.length };
50
- try { got.close(); } catch { /* */ }
51
- return info;
52
- }
53
- return { opened: false, error: String(err.message ?? err).slice(0, 120) };
54
- }
55
- bot.removeListener('windowOpen', onWin);
56
- if (bot._modsmithUnknownWindow) {
57
- return { opened: true, type: bot._modsmithUnknownWindow, slots: null, error: '自定义窗口(界面未知)' };
58
- }
59
- const got = win ?? bot.currentWindow ?? null;
60
- if (got) {
61
- const info = { opened: true, type: got.type, slots: got.slots?.length };
62
- try { got.close(); } catch { /* */ }
63
- await sleep(300);
64
- return info;
65
- }
66
- return { opened: false, error: timedOut ? '超时没开出界面' : '没有窗口' };
67
- }
68
-
69
- /**
70
- * 开一次探测会话:连服务器、搭平台、给一个 `probeOne(blockName)`,用完 `close()`。
71
- *
72
- * 抽出来是因为 `learn` 也要验站点。共用一个会话比每次重连省事,也保证两边
73
- * 用的是同一套"怎么算开窗"的口径。
74
- */
75
- export async function openProbeSession(opts) {
76
- const host = String(opts.host ?? '127.0.0.1');
77
- const port = Number(opts.port ?? 25565);
78
- const rconPort = Number(opts.rconPort ?? port + 10);
79
- const rconPassword = String(opts.rconPassword ?? '');
80
- const mcVersion = String(opts.mcVersion ?? '1.21.1');
81
- const username = String(opts.username ?? 'ModProbe');
82
- const bx = Number(opts.x ?? 400);
83
- const by = Number(opts.y ?? 200);
84
- const bz = Number(opts.z ?? 400);
85
- const openMs = Number(opts.timeout ?? 3000);
86
-
87
- if (opts.modsDir) setModsDir(opts.modsDir);
88
- const pack = opts.pack ? loadModPack(opts.pack) : null;
89
-
90
- const cmd = await makeRcon(host, rconPort, rconPassword);
91
- const bot = mineflayer.createBot({ host, port, username, version: mcVersion, auth: 'offline' });
92
- if (pack) installModRegistry(bot, pack);
93
- bot.loadPlugin((b) => installMineflayerFixes(b, { extraDrop: MEASURE_DROPS }));
94
-
95
- await cmd(`forceload add ${bx - 8} ${bz - 8} ${bx + 8} ${bz + 8}`);
96
- await cmd(`fill ${bx - 6} ${by - 1} ${bz - 6} ${bx + 6} ${by - 1} ${bz + 6} minecraft:stone`);
97
- await cmd(`fill ${bx - 6} ${by} ${bz - 6} ${bx + 6} ${by + 4} ${bz + 6} minecraft:air`);
98
- await new Promise((res, rej) => { bot.once('spawn', res); bot.once('error', rej); });
99
- await cmd(`tp ${username} ${bx + 0.5} ${by} ${bz + 0.5}`);
100
- await sleep(2500);
101
-
102
- const target = { x: bx + 1, y: by, z: bz };
103
-
104
- /**
105
- * 等目标格子读回指定方块名(最多 timeoutMs)。
106
- * 固定 sleep 450ms 在模组方块上不够:真实事故是一半容器读回空名(客户端区块更新还没到),
107
- * 被当成"没测成"。按名等待把读滞后和"真的放不下"区分开。
108
- */
109
- const waitForBlockName = async (pos, name, timeoutMs = 15000) => {
110
- const deadline = Date.now() + timeoutMs;
111
- let last = null;
112
- for (;;) {
113
- last = bot.blockAt(new Vec3(pos.x, pos.y, pos.z));
114
- if (last?.name === name) return { block: last };
115
- if (Date.now() > deadline) return { block: last };
116
- await sleep(150);
117
- }
118
- };
119
-
120
- /** 放一个方块并右键,看它开不开窗。 */
121
- const probeOne = async (name) => {
122
- await cmd(`setblock ${target.x} ${target.y} ${target.z} ${name}`);
123
- const { block } = await waitForBlockName(target, name);
124
- let result;
125
- if (!block) result = { placed: false, opened: false, error: 'blockAt 读不到(区块没到?)' };
126
- else if (block.name !== name) result = { placed: false, opened: false, error: `放下去是 ${block.name || '(空名)'}` };
127
- else result = { placed: true, ...(await tryOpen(bot, block, openMs)) };
128
- await cmd(`setblock ${target.x} ${target.y} ${target.z} minecraft:air`);
129
- await sleep(350);
130
- return result;
131
- };
132
-
133
- /** 验一组站点声明。 */
134
- const probeStations = async (stations) => {
135
- const out = [];
136
- for (const st of stations ?? []) {
137
- if (!st?.block) continue;
138
- const r = await probeOne(st.block);
139
- const { matches, why } = stationMatches(st.kind, {
140
- opensWindow: r.opened === true,
141
- windowType: r.type ?? null,
142
- placed: r.placed !== false,
143
- });
144
- out.push({
145
- id: st.id, block: st.block, kind: st.kind,
146
- placed: r.placed !== false,
147
- opensWindow: r.opened === true,
148
- windowType: r.type ?? null,
149
- slots: r.slots ?? null,
150
- matches,
151
- why,
152
- observed: r.opened === true ? `开出菜单(${r.type ?? '未知类型'})` : (r.placed === false ? '没放下去' : '不开窗'),
153
- error: r.opened ? null : r.error,
154
- });
155
- }
156
- return out;
157
- };
158
-
159
- const close = async () => {
160
- try { await cmd(`fill ${bx - 6} ${by} ${bz - 6} ${bx + 6} ${by + 4} ${bz + 6} minecraft:air`); } catch { /* */ }
161
- try { await cmd(`forceload remove ${bx - 8} ${bz - 8} ${bx + 8} ${bz + 8}`); } catch { /* */ }
162
- await sleep(200);
163
- try { bot.quit(); } catch { /* */ }
164
- };
165
-
166
- return { probeOne, probeStations, close, bot, cmd };
167
- }
168
-
169
- /**
170
- * 给 `learn` 用的探测器:每次调用独立开一次会话,跑完收尾。
171
- *
172
- * 不缓存连接的原因:`learn` 会重试好几轮,用一条长连接就要处理"上一轮是不是把 bot
173
- * 留在半开状态";每次重连虽然慢十几秒,但每轮都是干净状态,出错也好定位。
174
- */
175
- export function makeStationProbe(opts) {
176
- return async (stations) => {
177
- if (!Array.isArray(stations) || stations.length === 0) return [];
178
- let session = null;
179
- try {
180
- session = await openProbeSession(opts);
181
- return await session.probeStations(stations);
182
- } catch (err) {
183
- console.error(` 实测验证连不上服务器(这一轮跳过验证):${err.message ?? err}`);
184
- return [];
185
- } finally {
186
- if (session) {
187
- try { await session.close(); } catch { /* */ }
188
- }
189
- }
190
- };
191
- }
192
-
193
- /**
194
- * @param {Record<string, any>} args CLI 参数
195
- * @param {{fatal: (m: string) => never}} io
196
- */
197
- export default async function cmdProbe(args, io) {
198
- const manifestPath = args._[1];
199
- if (!manifestPath) io.fatal('probe 要给一个 manifest 路径');
200
- const manifest = readManifest(manifestPath);
201
- if (!manifest) io.fatal(`读不了 manifest:${manifestPath}`);
202
-
203
- const packName = String(args.pack ?? '');
204
- if (!packName) io.fatal('probe 要给 --pack <包名>(mods/ 下的文件名,不带 .json)');
205
- if (typeof args['mods-dir'] === 'string') setModsDir(args['mods-dir']);
206
- const pack = loadModPack(packName);
207
- if (!pack) io.fatal(`读不到包 ${packName}(需要 ${packName}.json 与 ${packName}-ids.json)`);
208
-
209
- const verdict = checkServerMods(
210
- pack.ids?.serverMods,
211
- typeof args['server-mods'] === 'string' ? args['server-mods'] : undefined,
212
- );
213
- for (const line of verdict.lines) console.error(` ${line}`);
214
- if (verdict.fatal) io.fatal('ids 与这台服务器对不上(见上)——过期的数据测出来的站点结论也是错的');
215
-
216
- const session = await openProbeSession({
217
- host: args.host, port: args.port, rconPort: args['rcon-port'],
218
- rconPassword: args['rcon-password'], mcVersion: args['mc-version'],
219
- username: args.username ?? 'ModProbe', pack: packName,
220
- modsDir: typeof args['mods-dir'] === 'string' ? args['mods-dir'] : undefined,
221
- x: args.x, y: args.y, z: args.z, timeout: args.timeout,
222
- });
223
-
224
- // 1) 容器候选:实测说了算
225
- const containerOk = [];
226
- const containerNo = []; // 测过,确实打不开
227
- const containerUntested = []; // 没测成(放不下去),保持原判
228
- const candidates = Array.isArray(manifest.containers) ? manifest.containers : [];
229
- for (const name of candidates) {
230
- const r = await session.probeOne(name);
231
- if (r.opened) {
232
- containerOk.push({ name, type: r.type, slots: r.slots });
233
- } else if (r.placed === false) {
234
- // 没放下去 = 没测成,不是"不能用"。注册表 id 不对时会走到这里。
235
- // 这种必须保留在容器表里,否则一次没测成就把好好的橱柜删了。
236
- containerUntested.push({ name, error: r.error });
237
- } else {
238
- containerNo.push({ name, error: r.error });
239
- }
240
- }
241
-
242
- // 2) 站点:右键开不开窗 —— "声明 vs 实测"唯一能自动化的硬约束
243
- const stationResult = await session.probeStations(manifest.stations ?? []);
244
-
245
- // 3) --scan-blocks:把候选方块逐个右键一遍,记下"开不开窗、开的什么窗"。
246
- // 这是 learn --infer 的证据来源:遇到名字与功能不对应的 mod(叫 *_crate 却是
247
- // 机器、叫 *_machine 却是纯装饰),只有实测能分开。
248
- let blockScan = null;
249
- if (args['scan-blocks'] === true) {
250
- const { scanCandidates } = await import('./stations.mjs');
251
- const { candidates, skipped } = scanCandidates(manifest, { limit: Number(args['scan-limit'] ?? 400) });
252
- console.error(` 方块扫描:${candidates.length} 个候选(跳过 容器 ${skipped.container} / 多状态 ${skipped.multiState} / 装饰 ${skipped.decorative})`);
253
- const evidence = {};
254
- let i = 0;
255
- for (const name of candidates) {
256
- i += 1;
257
- const r = await session.probeOne(name);
258
- if (r.placed === false) { evidence[name] = { placed: false, error: r.error }; continue; }
259
- evidence[name] = {
260
- placed: true,
261
- opensWindow: r.opened === true,
262
- ...(r.type ? { windowType: r.type } : {}),
263
- ...(typeof r.slots === 'number' ? { slots: r.slots } : {}),
264
- };
265
- if (i % 20 === 0) console.error(` ${i}/${candidates.length}`);
266
- }
267
- const opened = Object.values(evidence).filter((e) => e.opensWindow).length;
268
- blockScan = { candidates: candidates.length, opened, skipped, evidence };
269
- console.error(` 方块扫描完成:${opened}/${candidates.length} 个会开窗`);
270
- }
271
-
272
- await session.close();
273
-
274
- const out = {
275
- mod: manifest.mod,
276
- probedAt: new Date().toISOString(),
277
- containers: {
278
- // 只留实测能开的——不能开的绝不能进容器表(机器人去取的时候会一直超时)
279
- ok: containerOk.map((c) => c.name),
280
- detail: containerOk,
281
- failed: containerNo,
282
- untested: containerUntested,
283
- },
284
- stations: stationResult,
285
- ...(blockScan ? { blockScan } : {}),
286
- };
287
-
288
- if (typeof args.out === 'string') {
289
- const { writeIds } = await import('./manifest-io.mjs');
290
- writeIds(args.out, out);
291
- console.error(` 写入 ${args.out}`);
292
- }
293
-
294
- // 回写清单:这一步是"自动化"的最后一米,不写回去,probe 就只是个报告生成器
295
- if (args['write-manifest'] === true) {
296
- const { writeManifest } = await import('./manifest-io.mjs');
297
- const { notes } = applyProbe(manifest, out);
298
- writeManifest(manifestPath, manifest);
299
- console.error(` 已回写 ${manifestPath}`);
300
- for (const n of notes) console.error(` ${n}`);
301
- }
302
-
303
- console.error(`\n容器:能开 ${containerOk.length} / 候选 ${candidates.length}`);
304
- for (const c of containerOk) console.error(` ✓ ${c.name} ${c.type} ${c.slots} 槽`);
305
- for (const c of containerNo) console.error(` ✗ ${c.name} ${c.error}`);
306
- for (const c of containerUntested) console.error(` ? ${c.name} 没测成(保持原判):${c.error}`);
307
- if (stationResult.length > 0) {
308
- console.error('\n站点:');
309
- for (const s of stationResult) {
310
- const mark = s.matches ? '✓' : '✗';
311
- console.error(` ${mark} ${s.block} 声明 ${s.kind},${s.opensWindow ? `实测开窗(${s.windowType ?? '?'})` : '实测不开窗'}`);
312
- if (!s.matches) console.error(` ${s.why}`);
313
- }
314
- }
315
- console.log(JSON.stringify(out, null, 2));
316
- }
1
+ /**
2
+ * `modsmith probe` —— 实测哪些站点/容器真的能用。
3
+ *
4
+ * 静态分析只能给出**候选**:名字像容器的 `*_crate` 实测打不开,而名字普通的橱柜能开。
5
+ * 所以凡是"这个方块右键会发生什么"的问题,都必须上真服务器问一次。
6
+ *
7
+ * 做的事(都在一块临时搭的平台上,测完清掉):
8
+ * 1. 容器候选逐个右键:记下开没开、开的是哪种菜单、多少槽
9
+ * 2. 站点候选逐个右键:GUI 类站点记 inventoryType;不开窗的标出来(那类要走侧车)
10
+ *
11
+ * 依赖:服务器已装好该 mod、开了 RCON、bot 能离线登录且有 OP(要 forceload/setblock/tp)。
12
+ */
13
+ import mineflayer from 'mineflayer';
14
+ import { Vec3 } from 'vec3';
15
+ import { readManifest } from './manifest-io.mjs';
16
+ import { writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { installModRegistry, MEASURE_DROPS, installMineflayerFixes, loadModPack, setModsDir } from './cortico-bridge.mjs';
19
+ import { applyProbe, stationMatches } from './manifest-probe.mjs';
20
+ import { checkServerMods } from './server-fingerprint.mjs';
21
+ import { makeRcon } from './rcon.mjs';
22
+
23
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
+
25
+ /**
26
+ * 右键一个方块,看它开不开窗。
27
+ * @returns {{ opened: boolean, type?: string, slots?: number, error?: string }}
28
+ */
29
+ async function tryOpen(bot, block, ms) {
30
+ let win = null;
31
+ // 自定义窗口类型会被 installMineflayerFixes 的 emit 守卫记在这里:prismarine-windows
32
+ // 不认识 → createWindow 返回 null → 窗口事件不会来。这种"开了但界面未知"必须算开窗。
33
+ bot._modsmithUnknownWindow = null;
34
+ const onWin = (w) => { win = w; };
35
+ bot.once('windowOpen', onWin);
36
+ const open = block.name.includes(':') ? bot.openBlock(block) : bot.openContainer(block);
37
+ let timedOut = false;
38
+ try {
39
+ await Promise.race([
40
+ open.catch((e) => { throw e; }),
41
+ sleep(ms).then(() => { timedOut = true; }),
42
+ ]);
43
+ } catch (err) {
44
+ bot.removeListener('windowOpen', onWin);
45
+ await sleep(150);
46
+ if (bot._modsmithUnknownWindow) {
47
+ return { opened: true, type: bot._modsmithUnknownWindow, slots: null, error: '自定义窗口(界面未知)' };
48
+ }
49
+ const got = win ?? bot.currentWindow ?? null;
50
+ if (got) {
51
+ const info = { opened: true, type: got.type, slots: got.slots?.length };
52
+ try { got.close(); } catch { /* */ }
53
+ return info;
54
+ }
55
+ return { opened: false, error: String(err.message ?? err).slice(0, 120) };
56
+ }
57
+ bot.removeListener('windowOpen', onWin);
58
+ if (bot._modsmithUnknownWindow) {
59
+ return { opened: true, type: bot._modsmithUnknownWindow, slots: null, error: '自定义窗口(界面未知)' };
60
+ }
61
+ const got = win ?? bot.currentWindow ?? null;
62
+ if (got) {
63
+ const info = { opened: true, type: got.type, slots: got.slots?.length };
64
+ try { got.close(); } catch { /* */ }
65
+ await sleep(300);
66
+ return info;
67
+ }
68
+ return { opened: false, error: timedOut ? '超时没开出界面' : '没有窗口' };
69
+ }
70
+
71
+ /**
72
+ * 开一次探测会话:连服务器、搭平台、给一个 `probeOne(blockName)`,用完 `close()`。
73
+ *
74
+ * 抽出来是因为 `learn` 也要验站点。共用一个会话比每次重连省事,也保证两边
75
+ * 用的是同一套"怎么算开窗"的口径。
76
+ */
77
+ export async function openProbeSession(opts) {
78
+ const host = String(opts.host ?? '127.0.0.1');
79
+ const port = Number(opts.port ?? 25565);
80
+ const rconPort = Number(opts.rconPort ?? port + 10);
81
+ const rconPassword = String(opts.rconPassword ?? '');
82
+ const mcVersion = String(opts.mcVersion ?? '1.21.1');
83
+ const username = String(opts.username ?? 'ModProbe');
84
+ const bx = Number(opts.x ?? 400);
85
+ const by = Number(opts.y ?? 200);
86
+ const bz = Number(opts.z ?? 400);
87
+ const openMs = Number(opts.timeout ?? 3000);
88
+
89
+ if (opts.modsDir) setModsDir(opts.modsDir);
90
+ const pack = opts.pack ? loadModPack(opts.pack) : null;
91
+
92
+ const cmd = await makeRcon(host, rconPort, rconPassword);
93
+ const bot = mineflayer.createBot({ host, port, username, version: mcVersion, auth: 'offline' });
94
+ if (pack) installModRegistry(bot, pack);
95
+ bot.loadPlugin((b) => installMineflayerFixes(b, { extraDrop: MEASURE_DROPS }));
96
+
97
+ await cmd(`forceload add ${bx - 8} ${bz - 8} ${bx + 8} ${bz + 8}`);
98
+ await cmd(`fill ${bx - 6} ${by - 1} ${bz - 6} ${bx + 6} ${by - 1} ${bz + 6} minecraft:stone`);
99
+ await cmd(`fill ${bx - 6} ${by} ${bz - 6} ${bx + 6} ${by + 4} ${bz + 6} minecraft:air`);
100
+ await new Promise((res, rej) => { bot.once('spawn', res); bot.once('error', rej); });
101
+ // probe 也是登录时收到 declare_recipes;顺手把它留给 recipes 步骤。
102
+ // 没有这一步,dump 接入的包在 onboard 跑到 recipes 时只能报“找不到捕获文件”。
103
+ if (pack && bot._modsmithDeclareRecipes) {
104
+ const dir = typeof opts.modsDir === 'string' ? opts.modsDir : 'mods';
105
+ writeFileSync(join(dir, `${opts.pack}-recipes.raw.bin`), bot._modsmithDeclareRecipes);
106
+ }
107
+ await cmd(`tp ${username} ${bx + 0.5} ${by} ${bz + 0.5}`);
108
+ await sleep(2500);
109
+
110
+ const target = { x: bx + 1, y: by, z: bz };
111
+
112
+ /**
113
+ * 等目标格子读回指定方块名(最多 timeoutMs)。
114
+ * 固定 sleep 450ms 在模组方块上不够:真实事故是一半容器读回空名(客户端区块更新还没到),
115
+ * 被当成"没测成"。按名等待把读滞后和"真的放不下"区分开。
116
+ */
117
+ const waitForBlockName = async (pos, name, timeoutMs = 15000) => {
118
+ const deadline = Date.now() + timeoutMs;
119
+ let last = null;
120
+ for (;;) {
121
+ last = bot.blockAt(new Vec3(pos.x, pos.y, pos.z));
122
+ if (last?.name === name) return { block: last };
123
+ if (Date.now() > deadline) return { block: last };
124
+ await sleep(150);
125
+ }
126
+ };
127
+
128
+ /** 放一个方块并右键,看它开不开窗。 */
129
+ const probeOne = async (name) => {
130
+ await cmd(`setblock ${target.x} ${target.y} ${target.z} ${name}`);
131
+ const { block } = await waitForBlockName(target, name);
132
+ let result;
133
+ if (!block) result = { placed: false, opened: false, error: 'blockAt 读不到(区块没到?)' };
134
+ else if (block.name !== name) result = { placed: false, opened: false, error: `放下去是 ${block.name || '(空名)'}` };
135
+ else result = { placed: true, ...(await tryOpen(bot, block, openMs)) };
136
+ await cmd(`setblock ${target.x} ${target.y} ${target.z} minecraft:air`);
137
+ await sleep(350);
138
+ return result;
139
+ };
140
+
141
+ /** 验一组站点声明。 */
142
+ const probeStations = async (stations) => {
143
+ const out = [];
144
+ for (const st of stations ?? []) {
145
+ if (!st?.block) continue;
146
+ const r = await probeOne(st.block);
147
+ const { matches, why } = stationMatches(st.kind, {
148
+ opensWindow: r.opened === true,
149
+ windowType: r.type ?? null,
150
+ placed: r.placed !== false,
151
+ });
152
+ out.push({
153
+ id: st.id, block: st.block, kind: st.kind,
154
+ placed: r.placed !== false,
155
+ opensWindow: r.opened === true,
156
+ windowType: r.type ?? null,
157
+ slots: r.slots ?? null,
158
+ matches,
159
+ why,
160
+ observed: r.opened === true ? `开出菜单(${r.type ?? '未知类型'})` : (r.placed === false ? '没放下去' : '不开窗'),
161
+ error: r.opened ? null : r.error,
162
+ });
163
+ }
164
+ return out;
165
+ };
166
+
167
+ const close = async () => {
168
+ try { await cmd(`fill ${bx - 6} ${by} ${bz - 6} ${bx + 6} ${by + 4} ${bz + 6} minecraft:air`); } catch { /* */ }
169
+ try { await cmd(`forceload remove ${bx - 8} ${bz - 8} ${bx + 8} ${bz + 8}`); } catch { /* */ }
170
+ await sleep(200);
171
+ try { bot.quit(); } catch { /* */ }
172
+ cmd.close();
173
+ };
174
+
175
+ return { probeOne, probeStations, close, bot, cmd };
176
+ }
177
+
178
+ /**
179
+ * 给 `learn` 用的探测器:每次调用独立开一次会话,跑完收尾。
180
+ *
181
+ * 不缓存连接的原因:`learn` 会重试好几轮,用一条长连接就要处理"上一轮是不是把 bot
182
+ * 留在半开状态";每次重连虽然慢十几秒,但每轮都是干净状态,出错也好定位。
183
+ */
184
+ export function makeStationProbe(opts) {
185
+ return async (stations) => {
186
+ if (!Array.isArray(stations) || stations.length === 0) return [];
187
+ let session = null;
188
+ try {
189
+ session = await openProbeSession(opts);
190
+ return await session.probeStations(stations);
191
+ } catch (err) {
192
+ console.error(` 实测验证连不上服务器(这一轮跳过验证):${err.message ?? err}`);
193
+ return [];
194
+ } finally {
195
+ if (session) {
196
+ try { await session.close(); } catch { /* */ }
197
+ }
198
+ }
199
+ };
200
+ }
201
+
202
+ /**
203
+ * @param {Record<string, any>} args CLI 参数
204
+ * @param {{fatal: (m: string) => never}} io
205
+ */
206
+ export default async function cmdProbe(args, io) {
207
+ const manifestPath = args._[1];
208
+ if (!manifestPath) io.fatal('probe 要给一个 manifest 路径');
209
+ const manifest = readManifest(manifestPath);
210
+ if (!manifest) io.fatal(`读不了 manifest:${manifestPath}`);
211
+
212
+ const packName = String(args.pack ?? '');
213
+ if (!packName) io.fatal('probe 要给 --pack <包名>(mods/ 下的文件名,不带 .json)');
214
+ if (typeof args['mods-dir'] === 'string') setModsDir(args['mods-dir']);
215
+ const pack = loadModPack(packName);
216
+ if (!pack) io.fatal(`读不到包 ${packName}(需要 ${packName}.json 与 ${packName}-ids.json)`);
217
+
218
+ const verdict = checkServerMods(
219
+ pack.ids?.serverMods,
220
+ typeof args['server-mods'] === 'string' ? args['server-mods'] : undefined,
221
+ );
222
+ for (const line of verdict.lines) console.error(` ${line}`);
223
+ if (verdict.fatal) io.fatal('ids 与这台服务器对不上(见上)——过期的数据测出来的站点结论也是错的');
224
+
225
+ const session = await openProbeSession({
226
+ host: args.host, port: args.port, rconPort: args['rcon-port'],
227
+ rconPassword: args['rcon-password'], mcVersion: args['mc-version'],
228
+ username: args.username ?? 'ModProbe', pack: packName,
229
+ modsDir: typeof args['mods-dir'] === 'string' ? args['mods-dir'] : undefined,
230
+ x: args.x, y: args.y, z: args.z, timeout: args.timeout,
231
+ });
232
+
233
+ // 1) 容器候选:实测说了算
234
+ const containerOk = [];
235
+ const containerNo = []; // 测过,确实打不开
236
+ const containerUntested = []; // 没测成(放不下去),保持原判
237
+ const candidates = Array.isArray(manifest.containers) ? manifest.containers : [];
238
+ for (const name of candidates) {
239
+ const r = await session.probeOne(name);
240
+ if (r.opened) {
241
+ containerOk.push({ name, type: r.type, slots: r.slots });
242
+ } else if (r.placed === false) {
243
+ // 没放下去 = 没测成,不是"不能用"。注册表 id 不对时会走到这里。
244
+ // 这种必须保留在容器表里,否则一次没测成就把好好的橱柜删了。
245
+ containerUntested.push({ name, error: r.error });
246
+ } else {
247
+ containerNo.push({ name, error: r.error });
248
+ }
249
+ }
250
+
251
+ // 2) 站点:右键开不开窗 —— "声明 vs 实测"唯一能自动化的硬约束
252
+ const stationResult = await session.probeStations(manifest.stations ?? []);
253
+
254
+ // 3) --scan-blocks:把候选方块逐个右键一遍,记下"开不开窗、开的什么窗"。
255
+ // 这是 learn --infer 的证据来源:遇到名字与功能不对应的 mod(叫 *_crate 却是
256
+ // 机器、叫 *_machine 却是纯装饰),只有实测能分开。
257
+ let blockScan = null;
258
+ if (args['scan-blocks'] === true) {
259
+ const { scanCandidates } = await import('./stations.mjs');
260
+ const { candidates, skipped } = scanCandidates(manifest, { limit: Number(args['scan-limit'] ?? 400) });
261
+ console.error(` 方块扫描:${candidates.length} 个候选(跳过 容器 ${skipped.container} / 多状态 ${skipped.multiState} / 装饰 ${skipped.decorative})`);
262
+ const evidence = {};
263
+ let i = 0;
264
+ for (const name of candidates) {
265
+ i += 1;
266
+ const r = await session.probeOne(name);
267
+ if (r.placed === false) { evidence[name] = { placed: false, error: r.error }; continue; }
268
+ evidence[name] = {
269
+ placed: true,
270
+ opensWindow: r.opened === true,
271
+ ...(r.type ? { windowType: r.type } : {}),
272
+ ...(typeof r.slots === 'number' ? { slots: r.slots } : {}),
273
+ };
274
+ if (i % 20 === 0) console.error(` ${i}/${candidates.length}`);
275
+ }
276
+ const opened = Object.values(evidence).filter((e) => e.opensWindow).length;
277
+ blockScan = { candidates: candidates.length, opened, skipped, evidence };
278
+ console.error(` 方块扫描完成:${opened}/${candidates.length} 个会开窗`);
279
+ }
280
+
281
+ await session.close();
282
+
283
+ const out = {
284
+ mod: manifest.mod,
285
+ probedAt: new Date().toISOString(),
286
+ containers: {
287
+ // 只留实测能开的——不能开的绝不能进容器表(机器人去取的时候会一直超时)
288
+ ok: containerOk.map((c) => c.name),
289
+ detail: containerOk,
290
+ failed: containerNo,
291
+ untested: containerUntested,
292
+ },
293
+ stations: stationResult,
294
+ ...(blockScan ? { blockScan } : {}),
295
+ };
296
+
297
+ if (typeof args.out === 'string') {
298
+ const { writeIds } = await import('./manifest-io.mjs');
299
+ writeIds(args.out, out);
300
+ console.error(` 写入 ${args.out}`);
301
+ }
302
+
303
+ // 回写清单:这一步是"自动化"的最后一米,不写回去,probe 就只是个报告生成器
304
+ if (args['write-manifest'] === true) {
305
+ const { writeManifest } = await import('./manifest-io.mjs');
306
+ const { notes } = applyProbe(manifest, out);
307
+ writeManifest(manifestPath, manifest);
308
+ console.error(` 已回写 ${manifestPath}`);
309
+ for (const n of notes) console.error(` ${n}`);
310
+ }
311
+
312
+ console.error(`\n容器:能开 ${containerOk.length} / 候选 ${candidates.length}`);
313
+ for (const c of containerOk) console.error(` ✓ ${c.name} ${c.type} ${c.slots} 槽`);
314
+ for (const c of containerNo) console.error(` ✗ ${c.name} ${c.error}`);
315
+ for (const c of containerUntested) console.error(` ? ${c.name} 没测成(保持原判):${c.error}`);
316
+ if (stationResult.length > 0) {
317
+ console.error('\n站点:');
318
+ for (const s of stationResult) {
319
+ const mark = s.matches ? '✓' : '✗';
320
+ console.error(` ${mark} ${s.block} 声明 ${s.kind},${s.opensWindow ? `实测开窗(${s.windowType ?? '?'})` : '实测不开窗'}`);
321
+ if (!s.matches) console.error(` ${s.why}`);
322
+ }
323
+ }
324
+ console.log(JSON.stringify(out, null, 2));
325
+ }