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/bin/modsmith.mjs CHANGED
@@ -1,405 +1,573 @@
1
- #!/usr/bin/env node
2
- /**
3
- * modsmith CLI。每个子命令都能单独跑,参数全显式,不读隐藏配置。
4
- */
5
- import { existsSync, readFileSync } from 'node:fs';
6
- import { join, resolve, basename } from 'node:path';
7
- import { findExistingPack, inspectJar } from '../src/inspect.mjs';
8
- import { readSidecar, writeSidecar, sidecarTemplateFrom } from '../src/crops-sidecar.mjs';
9
- import { writeManifest, manifestPaths } from '../src/manifest-io.mjs';
10
- import { VERSION } from '../src/index.mjs';
11
-
12
- const USAGE = `modsmith ${VERSION} — 把 Fabric mod jar 变成机器人能用的语义清单
13
-
14
- 用法:
15
- modsmith inspect <mod.jar> [选项]
16
- 静态提取方块/物品/配方/tag/中文名,推断作物。不需要服务器、不调 LLM。
17
-
18
- --out <dir> 写到这个目录(默认 ./mods)
19
- --sidecar <file> 作物覆盖表(人手纠正推断的地方)
20
- --write-sidecar <file> 把推断结果写成 sidecar 模板,供人改
21
- --pack <name> 显式包名(要接已有部署就用它,逐字对上 mods.pack)
22
- --mc-version <ver> 包名里的 MC 版本,默认 1.21.1
23
- --namespace <ns> jar 里读不到 id 时手动指定
24
- --quiet 只打一行结果
25
-
26
- modsmith probe <manifest.json> [选项]
27
- 实测哪些容器/站点真能用(名字像不等于能用)。要用 --pack 指包名。
28
-
29
- --pack <name> mods/ 下的文件名(不带 .json)
30
- --mods-dir <dir> manifest/ids 目录,默认 ./mods
31
- --server-mods <dir> 服务器 mods 目录:ids 指纹对不上就拒绝跑
32
- --timeout <ms> 每次右键等多久,默认 3000
33
- --write-manifest 把实测结果写回清单(容器表以实测为准)
34
- 其余参数与 calibrate 相同。
35
-
36
- modsmith calibrate <manifest.json> [选项]
37
- 连一台装好 mod 的服务器,用 /setblock + /give 量出每个方块状态与物品的数字 id。
38
-
39
- --ids <file> 输出 ids.json(默认与 manifest 同名加 -ids)
40
- --host <host> 默认 127.0.0.1
41
- --port <port> 游戏端口,默认 25565
42
- --rcon-port <port> RCON 端口,默认 port+10
43
- --rcon-password <pw> RCON 密码
44
- --mc-version <ver> 协议版本,默认 1.21.1
45
- --x/--y/--z <n> 测试用的空地中心,默认 400/200/400
46
- --place 额外做一遍"真实放置"校准(给 setblock 改不动属性的方块)
47
- --resume 接着上次量:已有结果里量全的方块跳过,适合断点续跑
48
- --fresh 不复用旧 ids,整份重测(换了/加了 jar 之后必须用它;
49
- 旧 ids 会整体位移,指纹闸会拒绝续用)
50
- --server-mods <dir> 服务器 mods 目录:把指纹记进 ids;加载时对不上就拒绝
51
- (不提指纹的那次量无法发现"换过服务器/换过 jar")
52
- --blocks-only 只量方块(配合 --ids 复用已有的物品结果)
53
- --items-only 只量物品(复用 --ids 里已有的方块结果,省时间)
54
- --no-selfcheck 跳过量完后的自检(不推荐:错 id 会被写进清单)
55
- --skip-block <n,..> 跳过点名方块:放上去服务端会崩的引信方块(看崩溃报告点名)
56
- --debug-items 逐件打印槽位与 type,排查物品校准用
57
-
58
- modsmith verify <manifest.json> [选项]
59
- 冒烟:连服务器,检查方块认得、物品能拿、配方能合。只读,不改世界。
60
-
61
- --pack <name> mods/ 下的文件名
62
- --ids <file> ids.json(默认与 manifest 同名加 -ids)
63
- --server-mods <dir> 服务器 mods 目录:ids 指纹对不上就拒绝跑
64
- --mods-dir <dir> manifest/ids 目录,默认 ./mods
65
- 其余参数与 calibrate 相同。
66
-
67
- modsmith recipes <manifest.json> [选项]
68
- 把服务端真配方(calibrate 捕获的 declare_recipes)翻进清单,替换 jar 里读的静态数据。
69
- 解析失败的整包拒绝、翻不出名字的条目跳过、成功率 <95% 不写回(--threshold 调)。
70
-
71
- --capture <file> 原始捕获(默认 <manifest 同名>-recipes.raw.bin)
72
- --ids <file> 数字 id 反查表(默认 <manifest 同名>-ids.json)
73
- --ids-all <dir> 同一服务器装了多个包时,把目录里所有 *-ids.json 合起来用
74
- (捕获的配方是整台服务器的,第二份清单必然碰到别人的物品;
75
- 各份 ids 指纹必须一致,否则拒绝——id 随注册顺序整体位移)
76
- --serializers <file> 模组自定义序列化器文法(默认 sidesheets/<pack>.serializers.json)
77
- --write 真写回 manifest(默认试运行)
78
- --threshold <0..1> 成功率闸门,默认 0.95
79
- --discover 不解析清单,改跑未知序列化器的文法发现:用配方名切段给出
80
- 每条 payload 的精确边界,秒级找回自定义类型文法。
81
- 产出永远是待审候选(confirmed:false);人核对后抄进正式
82
- sidesheet 才生效——机器不替人拍板。
83
- --accept 人看过表之后采纳:把发现结果写进正式 sidesheet(name 从
84
- 清单的 type 字段推断)。之后 recipes --write 才会用它。
85
- --no-discover 解析失败时别自动跑发现(默认会自动跑并打印待审表)
86
- --budget <n> 文法搜索的解析尝试预算,默认 12000000
87
- --max-candidates <n> 每个类型保留几个候选
88
- --out <file> 发现结果的落盘路径(--write 时用)
89
- --server-mods <dir> 服务器 mods 目录:发现时从所有 jar + 原版 jar 扫配方名,
90
- 名字清单越全,切段越准(数据包跨命名空间加的配方查不到)
91
-
92
- modsmith onboard <mod.jar> --pack <name> [选项]
93
- 新包接入一条龙:inspect → calibrate → probe → verify → recipes → sidecar。
94
- 前提:jar 已经装进服务器 mods 目录(onboard 绝不自动装)。
95
- 哪步失败就停在哪,修完重跑(配 --resume 跳过已有产物的步骤)。
96
-
97
- --pack <name> 必填:包名(mods/ 下的文件名,不带 .json)
98
- --out <dir> manifest/ids 目录,默认 ./mods
99
- --resume 产物已存在的步骤直接跳过
100
- --skip <id,id,…> 显式跳过某些步骤(inspect/calibrate/probe/verify/recipes/sidecar)
101
- --plan 只打印步骤清单,不执行
102
- --dry-recipes recipes 步骤不写回 manifest(默认写,95% 闸门兜底)
103
- --no-build sidecar 只出配置不编译(也就不写回命令)
104
- 其余 --host/--port/--rcon-port/--rcon-password/--server-mods/--mc-version/--x/y/z
105
- 与 calibrate 相同,转给需要服务器的步骤。
106
-
107
- modsmith merge <ids1.json> <ids2.json> ... --out <file>
108
- 把分阶段量出来的 ids 合并(后者只覆盖它有数据的部分)。
109
-
110
- modsmith learn <manifest.json> [选项]
111
- 让 LLM 读清单 + 可选 wiki,产出站点定义与玩法说明(只写数据,不写代码)。
112
-
113
- --wiki <url|file> 参考资料(mcmod 页面 / 导出的文本)
114
- --rounds <n> 最多重试几轮,默认 3
115
- --port / --rcon-password 给了就上服务器验证站点声明(强烈建议)
116
- --no-probe 不做实测验证,只做结构校验
117
- --timeout <ms> 每次右键等多久,默认 3000
118
- --mods-dir <dir> 注册表注入用,默认 ./mods
119
- --stations 额外验收工作站:放材料、发命令、断言产物(L3)
120
- --scan-blocks 把候选方块逐个右键一遍,记录开窗行为(给 learn --infer 当证据)
121
- --scan-limit <n> 方块扫描上限,默认 400
122
- --infer 规则推断工作站(不调模型):配方类型 → 方块词干/工具/实测证据
123
- --evidence <file> probe --scan-blocks 的输出,作为机制判断的依据(强烈建议)
124
- --force 配 --infer:整段重推,连人工站点的命令一起覆盖。
125
- 默认是合并:人工站点原样保留(只补 recipeType/toolSuffix),
126
- 只追加没被占用的方块
127
- 模型走环境变量:MODSMITH_BASE_URL / MODSMITH_API_KEY / MODSMITH_MODEL
128
- MODSMITH_MAX_TOKENS / MODSMITH_REASONING_EFFORT(推理模型别把预算烧在思考上)
129
-
130
- modsmith sidecar <manifest.json> [选项]
131
- 产出 sidecar-recipe 型站点的服务端侧车:一份站点配置 JSON(通用模组运行时读它)
132
- + 编译一次通用模组(templates/sidecar-generic,每个 MC 版本编译一回就够)。
133
- 编译通过后才把命令写回清单。
134
-
135
- --out <dir> 产物目录(默认 ./sidecars)
136
- --station <id> 只处理某一个站点
137
- --op-level <n> 命令要求的权限等级,默认 2(0 = 不设门)
138
- --mc-version <ver> 工具链版本查 GRADLE_VERSIONS 表,默认 1.21.1
139
- --no-build 不编译;**默认也不写回清单**,因为没验过
140
- --write-command 强制写回命令(配 --no-build 时表示你自担风险)
141
- Gradle 从 MODSMITH_GRADLE 或 PATH 找
142
-
143
- modsmith --version
144
- modsmith --help
145
- `;
146
-
147
- function parseArgs(argv) {
148
- const out = { _: [] };
149
- for (let i = 0; i < argv.length; i++) {
150
- const a = argv[i];
151
- if (!a.startsWith('--')) { out._.push(a); continue; }
152
- const key = a.slice(2);
153
- const next = argv[i + 1];
154
- if (next === undefined || next.startsWith('--')) { out[key] = true; continue; }
155
- out[key] = next;
156
- i++;
157
- }
158
- return out;
159
- }
160
-
161
- function fatal(msg) {
162
- console.error(`✗ ${msg}`);
163
- process.exit(1);
164
- }
165
-
166
- async function cmdInspect(args) {
167
- const jar = args._[1];
168
- if (!jar) fatal('inspect 要给一个 jar 路径');
169
- if (!existsSync(jar)) fatal(`找不到 ${jar}`);
170
-
171
- const sidecarPath = typeof args.sidecar === 'string' ? resolve(args.sidecar) : null;
172
- const { sidecar, drop } = readSidecar(sidecarPath);
173
-
174
- const result = inspectJar(jar, {
175
- namespace: typeof args.namespace === 'string' ? args.namespace : undefined,
176
- pack: typeof args.pack === 'string' ? args.pack : undefined,
177
- mcVersion: typeof args['mc-version'] === 'string' ? args['mc-version'] : undefined,
178
- sidecar,
179
- drop,
180
- });
181
-
182
- const outDir = resolve(typeof args.out === 'string' ? args.out : './mods');
183
- const paths = manifestPaths(outDir, result.packName);
184
- writeManifest(paths.manifest, result.manifest);
185
-
186
- // 同目录里已有这个 mod 的清单、但文件名不一样 → 多半是要接已有部署,把 --pack 告诉人
187
- // 只在没给 --pack 时提示:给了 --pack 说明是有意造第二份,再提示就会把自己刚写的指回去
188
- const existingPack = typeof args.pack === 'string'
189
- ? null
190
- : findExistingPack(outDir, result.manifest.mod, result.packName);
191
- if (existingPack) {
192
- console.error(` ! 这个目录里已有 ${existingPack}(同一个 mod)。`);
193
- console.error(` 要接它请重跑:modsmith inspect <jar> --out ${outDir} --pack ${existingPack}`);
194
- }
195
-
196
- if (typeof args['write-sidecar'] === 'string') {
197
- const target = resolve(args['write-sidecar']);
198
- writeSidecar(target, sidecarTemplateFrom(result.manifest));
199
- console.error(` 作物 sidecar 模板:${target}`);
200
- }
201
-
202
- if (!args.quiet) {
203
- const s = result.stats;
204
- console.error(`✓ ${result.manifest.mod} ${result.manifest.version}(namespace ${result.manifest.namespace})`);
205
- console.error(` 方块 ${s.blocks}(${s.states} 个状态) 物品 ${s.items} 配方 ${s.recipes}`);
206
- console.error(` 食物 ${s.foods} 作物 ${s.crops} 候选 tag ${s.candidates} 未解析 tag ${s.unresolvedTags}`);
207
- if (result.unresolvedTags.length > 0) {
208
- console.error(` 未解析的 tag(原版或别的 mod 提供,运行时按约定推):${result.unresolvedTags.slice(0, 8).join(', ')}${result.unresolvedTags.length > 8 ? ' …' : ''}`);
209
- }
210
- if (result.cropNotes.length > 0) {
211
- console.error(' 作物推断:');
212
- for (const n of result.cropNotes.slice(0, 12)) console.error(` ${n}`);
213
- }
214
- console.error(` 写入 ${paths.manifest}`);
215
- console.error(` 下一步:modsmith learn ${paths.manifest}`);
216
- }
217
- console.log(paths.manifest);
218
- }
219
-
220
- async function cmdLearn(args) {
221
- const manifestPath = args._[1];
222
- if (!manifestPath) fatal('learn 要给一个 manifest 路径');
223
- if (!existsSync(manifestPath)) fatal(`找不到 ${manifestPath}`);
224
-
225
- const { learn, learnStations } = await import('../src/learn.mjs');
226
-
227
- // --infer:规则优先,不调模型。从清单证据推工作站(L3 的默认路径)。
228
- if (args.infer === true) {
229
- const result = await learnStations(manifestPath, {
230
- evidencePath: typeof args.evidence === 'string' ? args.evidence : undefined,
231
- force: args.force === true,
232
- }, { fatal });
233
- if (!result.ok) fatal('规则推断没得到工作站');
234
- console.log(manifestPath);
235
- return;
236
- }
237
-
238
- const { askModel, probeModel } = await import('../src/llm.mjs');
239
-
240
- const probeInfo = await probeModel();
241
- if (!probeInfo.ok) {
242
- fatal(`没有可用的模型:${probeInfo.error}
243
- 换端点用环境变量 MODSMITH_BASE_URL / MODSMITH_MODEL`);
244
- }
245
- console.error(`模型:${probeInfo.model} @ ${probeInfo.base}`);
246
-
247
- let wikiText = '';
248
- if (typeof args.wiki === 'string') {
249
- if (existsSync(args.wiki)) wikiText = readFileSync(args.wiki, 'utf8');
250
- else {
251
- try {
252
- const res = await fetch(args.wiki);
253
- wikiText = await res.text();
254
- } catch (err) {
255
- console.error(` 取 wiki 失败(继续,不中断):${err.message}`);
256
- }
257
- }
258
- }
259
-
260
- // 给了服务器参数就自动上服务器验证;--no-probe 显式关掉
261
- let probe = null;
262
- const wantsProbe = args['no-probe'] !== true
263
- && (args.port !== undefined || args['rcon-password'] !== undefined || args['rcon-port'] !== undefined);
264
- if (wantsProbe) {
265
- const { makeStationProbe } = await import('../src/probe.mjs');
266
- probe = makeStationProbe({
267
- host: String(args.host ?? '127.0.0.1'),
268
- port: Number(args.port ?? 25565),
269
- rconPort: Number(args['rcon-port'] ?? Number(args.port ?? 25565) + 10),
270
- rconPassword: String(args['rcon-password'] ?? ''),
271
- mcVersion: String(args['mc-version'] ?? '1.21.1'),
272
- username: String(args.username ?? 'ModLearn'),
273
- pack: String(args.pack ?? ''),
274
- modsDir: typeof args['mods-dir'] === 'string' ? args['mods-dir'] : undefined,
275
- x: args.x, y: args.y, z: args.z,
276
- timeout: args.timeout,
277
- });
278
- console.error('实测验证:开(站点声明会拿真服务器对一遍)');
279
- } else {
280
- console.error('实测验证:关(要验就加 --port 与 --rcon-password)');
281
- }
282
-
283
- const result = await learn(manifestPath, {
284
- ask: (p) => askModel(p),
285
- probe,
286
- rounds: Number(args.rounds ?? 3),
287
- wikiText,
288
- }, { fatal });
289
-
290
- if (!result.ok) fatal('learn 没拿到可用结果');
291
- console.log(manifestPath);
292
- }
293
-
294
- async function cmdOnboard(args) {
295
- const jar = args._[1];
296
- if (!jar) fatal('onboard 要给一个 jar 路径');
297
- if (!existsSync(jar)) fatal(`找不到 ${jar}`);
298
- if (typeof args.pack !== 'string') fatal('onboard 要 --pack <name>(包名,mods/ 下的文件名,不带 .json)');
299
- const pack = args.pack;
300
- const modsDir = resolve(typeof args.out === 'string' ? args.out : './mods');
301
- const { onboardPlan } = await import('../src/onboard.mjs');
302
-
303
- const manifestPath = join(modsDir, `${pack}.json`);
304
- const idsPath = join(modsDir, `${pack}-ids.json`);
305
- let hasServerRecipes = false;
306
- let hasStations = false;
307
- if (existsSync(manifestPath)) {
308
- try {
309
- const m = JSON.parse(readFileSync(manifestPath, 'utf8'));
310
- hasServerRecipes = (m.recipes ?? []).some((r) => r?._source === 'server');
311
- // 有非 inferred 的站点 = 人已经审过(或历史上手写过),--resume 时才算完成。
312
- // 只有 inferred/needsReview 的站点仍要重跑:证据变了机制可能变。
313
- hasStations = (m.stations ?? []).some((st) => st && st.inferred !== true);
314
- } catch { /* 清单坏了就当没有,inspect 会重写 */ }
315
- }
316
- const probePath = join(modsDir, `${pack}-probe.json`);
317
- const plan = onboardPlan({
318
- jar,
319
- pack,
320
- modsDir,
321
- server: {
322
- host: args.host, port: args.port, rconPort: args['rcon-port'],
323
- rconPassword: args['rcon-password'], mcVersion: args['mc-version'],
324
- serverMods: args['server-mods'], x: args.x, y: args.y, z: args.z,
325
- },
326
- skip: typeof args.skip === 'string' ? args.skip.split(',').map((s) => s.trim()).filter(Boolean) : [],
327
- resume: args.resume === true,
328
- artifacts: {
329
- manifest: existsSync(manifestPath),
330
- ids: existsSync(idsPath),
331
- probe: existsSync(probePath),
332
- stations: hasStations,
333
- serverRecipes: hasServerRecipes,
334
- },
335
- dryRecipes: args['dry-recipes'] === true,
336
- noBuild: args['no-build'] === true,
337
- });
338
-
339
- if (args.plan === true) {
340
- for (const s of plan.steps) {
341
- console.log(`${s.done ? '✓' : s.blocked ? '✗' : '·'} ${s.id.padEnd(9)} ${s.title}${s.reason ? ` (${s.reason})` : ''}`);
342
- }
343
- console.log(`manifest: ${plan.manifestPath}\nids: ${plan.idsPath}`);
344
- return;
345
- }
346
-
347
- for (const s of plan.steps) {
348
- if (s.done) { console.error(`✓ ${s.id}:产物已存在,跳过(--resume)`); continue; }
349
- if (s.blocked) fatal(`${s.id} 无法执行:${s.reason}`);
350
- if (s.reason) console.error(` - ${s.id}:${s.reason}`);
351
- console.error(`▶ ${s.id}:${s.title}`);
352
- await runCommand(parseArgs(s.argv));
353
- }
354
- console.error('✓ onboard 全部步骤完成。侧车 jar 与站点配置在 sidecars/ 下——装不装进服务器由你决定(绝不自动装)。');
355
- }
356
-
357
- /** 命令分发(main 与 onboard 的步骤执行共用;argv[0] 是命令名)。 */
358
- async function runCommand(args) {
359
- const cmd = args._[0];
360
- switch (cmd) {
361
- case 'inspect': return cmdInspect(args);
362
- case 'recipes': {
363
- const mod = await import('../src/recipes-cli.mjs');
364
- return mod.default(args, { fatal });
365
- }
366
- case 'probe':
367
- case 'calibrate':
368
- case 'verify':
369
- case 'merge': {
370
- // 这几个要连服务器,模块在用到时才载入(inspect 不该背这个依赖)
371
- const mod = await import(`../src/${cmd}.mjs`);
372
- return mod.default(args, { fatal });
373
- }
374
- case 'learn': {
375
- return cmdLearn(args);
376
- }
377
- case 'sidecar': {
378
- const mod = await import('../src/sidecar.mjs');
379
- return mod.default(args, { fatal });
380
- }
381
- case 'onboard': {
382
- return cmdOnboard(args);
383
- }
384
- default:
385
- fatal(`不认识的命令:${cmd}(用 --help 看用法)`);
386
- }
387
- }
388
-
389
- async function main() {
390
- const argv = process.argv.slice(2);
391
- if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
392
- console.log(USAGE);
393
- return;
394
- }
395
- if (argv[0] === '--version' || argv[0] === '-V') {
396
- console.log(VERSION);
397
- return;
398
- }
399
- return runCommand(parseArgs(argv));
400
- }
401
-
402
- void main().catch((e) => {
403
- console.error(e?.stack || String(e));
404
- process.exit(1);
405
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * modsmith CLI。每个子命令都能单独跑,参数全显式,不读隐藏配置。
4
+ */
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join, resolve, basename } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { findExistingPack, inspectJar } from '../src/inspect.mjs';
9
+ import { readSidecar, writeSidecar, sidecarTemplateFrom } from '../src/crops-sidecar.mjs';
10
+ import { writeManifest, manifestPaths } from '../src/manifest-io.mjs';
11
+ import { VERSION } from '../src/index.mjs';
12
+
13
+ /** 随包分发的只读注册表 dump mod;不自动安装,只告诉人路径。 */
14
+ const DUMP_JAR = fileURLToPath(new URL('../templates/registry-dump/modsmith-dump-0.1.2.jar', import.meta.url));
15
+
16
+ const USAGE = `modsmith ${VERSION} — 把 Fabric mod jar 变成机器人能用的语义清单
17
+
18
+ 用法:
19
+ modsmith inspect <mod.jar> [选项]
20
+ 静态提取方块/物品/配方/tag/中文名,推断作物。不需要服务器、不调 LLM。
21
+
22
+ --out <dir> 写到这个目录(默认 ./mods)
23
+ --sidecar <file> 作物覆盖表(人手纠正推断的地方)
24
+ --write-sidecar <file> 把推断结果写成 sidecar 模板,供人改
25
+ --pack <name> 显式包名(要接已有部署就用它,逐字对上 mods.pack)
26
+ --mc-version <ver> 包名里的 MC 版本,默认 1.21.1
27
+ --namespace <ns> jar 里读不到 id 时手动指定
28
+ --quiet 只打一行结果
29
+
30
+ modsmith probe <manifest.json> [选项]
31
+ 实测哪些容器/站点真能用(名字像不等于能用)。要用 --pack 指包名。
32
+
33
+ --pack <name> mods/ 下的文件名(不带 .json)
34
+ --mods-dir <dir> manifest/ids 目录,默认 ./mods
35
+ --server-mods <dir> 服务器 mods 目录:ids 指纹对不上就拒绝跑
36
+ --timeout <ms> 每次右键等多久,默认 3000
37
+ --write-manifest 把实测结果写回清单(容器表以实测为准)
38
+ 其余参数与 calibrate 相同。
39
+
40
+ modsmith import-dump <dump.json> --manifest <manifest.json> [选项]
41
+ 从服务端注册表 dump(templates/registry-dump)直接生成 ids,秒级,替代
42
+ 逐状态 calibrate。dump 里的 stateId 是服务端真值;只做过滤与校验。
43
+
44
+ --manifest <file> 对应的语义清单(只保留它声明的方块/物品)
45
+ --ids <file> 输出 ids.json(默认与 manifest 同名加 -ids)
46
+ --server-mods <dir> 服务器 mods 目录:把指纹写进 ids(强烈建议)
47
+ --mc-version <ver> 原版上限判断用,默认 1.21.1
48
+
49
+ modsmith dump-jar
50
+ 打印随包分发的 registry-dump jar 绝对路径(不自动安装)。
51
+
52
+ modsmith conformance <manifest.json> [选项]
53
+ 接包验收:manifest 契约 + ids 交叉 + 注入真实 registry 后的方块/形状/作物/
54
+ 配方/实体行为。离线,不连服务器;失败退出码 1。
55
+
56
+ --ids <file> ids.json(缺省自动找同名 -ids.json)
57
+ --server-mods <dir> 服务器 mods 目录:核对 ids 指纹
58
+ --mc-version <ver> 原版上限,默认 1.21.1
59
+ --json <file> 把完整报告写成 JSON
60
+ --manifest-only 只查清单,不查 ids/registry
61
+ --live 连服务器,用真实 mineflayer bot 只读消费一遍
62
+ (--host/--port/--username;不 setblock、不聊天)
63
+ --rcon-port <port> --live 的写世界抽样(setblock/召唤/清理)用的 RCON
64
+ --rcon-password <pw> 给了才做写世界抽样;只应在专用测试服使用
65
+ --x/--y/--z <n> 抽样测试格,默认 400/100/400
66
+ --strict warning 也算失败
67
+
68
+ modsmith calibrate <manifest.json> [选项]
69
+ 连一台装好 mod 的服务器,用 /setblock + /give 量出每个方块状态与物品的数字 id。
70
+
71
+ --ids <file> 输出 ids.json(默认与 manifest 同名加 -ids)
72
+ --host <host> 默认 127.0.0.1
73
+ --port <port> 游戏端口,默认 25565
74
+ --rcon-port <port> RCON 端口,默认 port+10
75
+ --rcon-password <pw> RCON 密码
76
+ --mc-version <ver> 协议版本,默认 1.21.1
77
+ --x/--y/--z <n> 测试用的空地中心,默认 400/200/400
78
+ --place 额外做一遍"真实放置"校准(给 setblock 改不动属性的方块)
79
+ --resume 接着上次量:已有结果里量全的方块跳过,适合断点续跑
80
+ --fresh 不复用旧 ids,整份重测(换了/加了 jar 之后必须用它;
81
+ 旧 ids 会整体位移,指纹闸会拒绝续用)
82
+ --server-mods <dir> 服务器 mods 目录:把指纹记进 ids;加载时对不上就拒绝
83
+ (不提指纹的那次量无法发现"换过服务器/换过 jar")
84
+ --blocks-only 只量方块(配合 --ids 复用已有的物品结果)
85
+ --items-only 只量物品(复用 --ids 里已有的方块结果,省时间)
86
+ --no-selfcheck 跳过量完后的自检(不推荐:错 id 会被写进清单)
87
+ --skip-block <n,..> 跳过点名方块:放上去服务端会崩的引信方块(看崩溃报告点名)
88
+ --debug-items 逐件打印槽位与 type,排查物品校准用
89
+
90
+ modsmith verify <manifest.json> [选项]
91
+ 冒烟:连服务器,检查方块认得、物品能拿、配方能合。只读,不改世界。
92
+
93
+ --pack <name> mods/ 下的文件名
94
+ --ids <file> ids.json(默认与 manifest 同名加 -ids)
95
+ --server-mods <dir> 服务器 mods 目录:ids 指纹对不上就拒绝跑
96
+ --mods-dir <dir> manifest/ids 目录,默认 ./mods
97
+ 其余参数与 calibrate 相同。
98
+
99
+ modsmith recipes <manifest.json> [选项]
100
+ 把服务端真配方(calibrate 捕获的 declare_recipes)翻进清单,替换 jar 里读的静态数据。
101
+ 解析失败的整包拒绝、翻不出名字的条目跳过、成功率 <95% 不写回(--threshold 调)。
102
+
103
+ --capture <file> 原始捕获(默认 <manifest 同名>-recipes.raw.bin)
104
+ --ids <file> 数字 id 反查表(默认 <manifest 同名>-ids.json)
105
+ --ids-all <dir> 同一服务器装了多个包时,把目录里所有 *-ids.json 合起来用
106
+ (捕获的配方是整台服务器的,第二份清单必然碰到别人的物品;
107
+ 各份 ids 指纹必须一致,否则拒绝——id 随注册顺序整体位移)
108
+ --serializers <file> 模组自定义序列化器文法(默认 sidesheets/<pack>.serializers.json)
109
+ --write 真写回 manifest(默认试运行)
110
+ --threshold <0..1> 成功率闸门,默认 0.95
111
+ --discover 不解析清单,改跑未知序列化器的文法发现:用配方名切段给出
112
+ 每条 payload 的精确边界,秒级找回自定义类型文法。
113
+ 产出永远是待审候选(confirmed:false);人核对后抄进正式
114
+ sidesheet 才生效——机器不替人拍板。
115
+ --accept 人看过表之后采纳:把发现结果写进正式 sidesheet(name 从
116
+ 清单的 type 字段推断)。之后 recipes --write 才会用它。
117
+ --no-discover 解析失败时别自动跑发现(默认会自动跑并打印待审表)
118
+ --budget <n> 文法搜索的解析尝试预算,默认 12000000
119
+ --max-candidates <n> 每个类型保留几个候选
120
+ --out <file> 发现结果的落盘路径(--write 时用)
121
+ --server-mods <dir> 服务器 mods 目录:发现时从所有 jar + 原版 jar 扫配方名,
122
+ 名字清单越全,切段越准(数据包跨命名空间加的配方查不到)
123
+
124
+ modsmith onboard <mod.jar> --pack <name> [选项]
125
+ 新包接入一条龙:inspect → ids(dump/calibrate) → probe → verify → recipes → sidecar。
126
+ 前提:jar 已经装进服务器 mods 目录(onboard 绝不自动装)。
127
+ 有 registry dump 时默认走 import-dump(秒级);没有才 calibrate。
128
+ 哪步失败就停在哪,修完重跑(配 --resume 跳过已有产物的步骤)。
129
+
130
+ --pack <name> 必填:包名(mods/ 下的文件名,不带 .json)
131
+ --out <dir> manifest/ids 目录,默认 ./mods
132
+ --dump <file> 指定 registry dump JSON(templates/registry-dump 产出)
133
+ --no-dump 即使找到 dump 也强制用 calibrate
134
+ --resume 产物已存在的步骤直接跳过
135
+ --skip <id,id,…> 显式跳过某些步骤(inspect/ids/calibrate/probe/verify/recipes/sidecar)
136
+ --plan 只打印步骤清单,不执行
137
+ --dry-recipes recipes 步骤不写回 manifest(默认写,95% 闸门兜底)
138
+ --serializers <file> recipes 用的自定义序列化器 sidesheet(默认按 manifest.mod 找)
139
+ --no-build sidecar 只出配置不编译(也就不写回命令)
140
+ 其余 --host/--port/--rcon-port/--rcon-password/--server-mods/--mc-version/--x/y/z
141
+ 与 calibrate 相同,转给需要服务器的步骤。注意:dump 只替代 id 测量,
142
+ probe / verify / recipes / learn 仍然要跑。
143
+
144
+ modsmith merge <ids1.json> <ids2.json> ... --out <file>
145
+ 把分阶段量出来的 ids 合并(后者只覆盖它有数据的部分)。
146
+
147
+ modsmith learn <manifest.json> [选项]
148
+ 让 LLM 读清单 + 可选 wiki,产出站点定义与玩法说明(只写数据,不写代码)。
149
+
150
+ --wiki <url|file> 参考资料(mcmod 页面 / 导出的文本)
151
+ --rounds <n> 最多重试几轮,默认 3
152
+ --port / --rcon-password 给了就上服务器验证站点声明(强烈建议)
153
+ --no-probe 不做实测验证,只做结构校验
154
+ --timeout <ms> 每次右键等多久,默认 3000
155
+ --mods-dir <dir> 注册表注入用,默认 ./mods
156
+ --stations 额外验收工作站:放材料、发命令、断言产物(L3)
157
+ --scan-blocks 把候选方块逐个右键一遍,记录开窗行为(给 learn --infer 当证据)
158
+ --scan-limit <n> 方块扫描上限,默认 400
159
+ --infer 规则推断工作站(不调模型):配方类型 → 方块词干/工具/实测证据
160
+ --evidence <file> probe --scan-blocks 的输出,作为机制判断的依据(强烈建议)
161
+ --force 配 --infer:整段重推,连人工站点的命令一起覆盖。
162
+ 默认是合并:人工站点原样保留(只补 recipeType/toolSuffix),
163
+ 只追加没被占用的方块
164
+ 模型走环境变量:MODSMITH_BASE_URL / MODSMITH_API_KEY / MODSMITH_MODEL
165
+ MODSMITH_MAX_TOKENS / MODSMITH_REASONING_EFFORT(推理模型别把预算烧在思考上)
166
+
167
+ modsmith sidecar <manifest.json> [选项]
168
+ 产出 sidecar-recipe 型站点的服务端侧车:一份站点配置 JSON(通用模组运行时读它)
169
+ + 编译一次通用模组(templates/sidecar-generic,每个 MC 版本编译一回就够)。
170
+ 编译通过后才把命令写回清单。
171
+
172
+ --out <dir> 产物目录(默认 ./sidecars)
173
+ --station <id> 只处理某一个站点
174
+ --op-level <n> 命令要求的权限等级,默认 2(0 = 不设门)
175
+ --mc-version <ver> 工具链版本查 GRADLE_VERSIONS 表,默认 1.21.1
176
+ --no-build 不编译;**默认也不写回清单**,因为没验过
177
+ --write-command 强制写回命令(配 --no-build 时表示你自担风险)
178
+ Gradle 从 MODSMITH_GRADLE 或 PATH 找
179
+
180
+ modsmith --version
181
+ modsmith --help
182
+ `;
183
+
184
+ function parseArgs(argv) {
185
+ const out = { _: [] };
186
+ for (let i = 0; i < argv.length; i++) {
187
+ const a = argv[i];
188
+ if (!a.startsWith('--')) { out._.push(a); continue; }
189
+ const key = a.slice(2);
190
+ const next = argv[i + 1];
191
+ if (next === undefined || next.startsWith('--')) { out[key] = true; continue; }
192
+ out[key] = next;
193
+ i++;
194
+ }
195
+ return out;
196
+ }
197
+
198
+ function fatal(msg) {
199
+ console.error(`✗ ${msg}`);
200
+ process.exit(1);
201
+ }
202
+
203
+ async function cmdInspect(args) {
204
+ const jar = args._[1];
205
+ if (!jar) fatal('inspect 要给一个 jar 路径');
206
+ if (!existsSync(jar)) fatal(`找不到 ${jar}`);
207
+
208
+ const sidecarPath = typeof args.sidecar === 'string' ? resolve(args.sidecar) : null;
209
+ const { sidecar, drop } = readSidecar(sidecarPath);
210
+
211
+ const result = inspectJar(jar, {
212
+ namespace: typeof args.namespace === 'string' ? args.namespace : undefined,
213
+ pack: typeof args.pack === 'string' ? args.pack : undefined,
214
+ mcVersion: typeof args['mc-version'] === 'string' ? args['mc-version'] : undefined,
215
+ sidecar,
216
+ drop,
217
+ });
218
+
219
+ const outDir = resolve(typeof args.out === 'string' ? args.out : './mods');
220
+ const paths = manifestPaths(outDir, result.packName);
221
+ writeManifest(paths.manifest, result.manifest);
222
+
223
+ // 同目录里已有这个 mod 的清单、但文件名不一样 → 多半是要接已有部署,把 --pack 告诉人
224
+ // 只在没给 --pack 时提示:给了 --pack 说明是有意造第二份,再提示就会把自己刚写的指回去
225
+ const existingPack = typeof args.pack === 'string'
226
+ ? null
227
+ : findExistingPack(outDir, result.manifest.mod, result.packName);
228
+ if (existingPack) {
229
+ console.error(` ! 这个目录里已有 ${existingPack}(同一个 mod)。`);
230
+ console.error(` 要接它请重跑:modsmith inspect <jar> --out ${outDir} --pack ${existingPack}`);
231
+ }
232
+
233
+ if (typeof args['write-sidecar'] === 'string') {
234
+ const target = resolve(args['write-sidecar']);
235
+ writeSidecar(target, sidecarTemplateFrom(result.manifest));
236
+ console.error(` 作物 sidecar 模板:${target}`);
237
+ }
238
+
239
+ if (!args.quiet) {
240
+ const s = result.stats;
241
+ console.error(`✓ ${result.manifest.mod} ${result.manifest.version}(namespace ${result.manifest.namespace})`);
242
+ console.error(` 方块 ${s.blocks}(${s.states} 个状态) 物品 ${s.items} 配方 ${s.recipes}`);
243
+ console.error(` 食物 ${s.foods} 作物 ${s.crops} 候选 tag ${s.candidates} 未解析 tag ${s.unresolvedTags}`);
244
+ if (result.unresolvedTags.length > 0) {
245
+ console.error(` 未解析的 tag(原版或别的 mod 提供,运行时按约定推):${result.unresolvedTags.slice(0, 8).join(', ')}${result.unresolvedTags.length > 8 ? ' …' : ''}`);
246
+ }
247
+ if (result.cropNotes.length > 0) {
248
+ console.error(' 作物推断:');
249
+ for (const n of result.cropNotes.slice(0, 12)) console.error(` ${n}`);
250
+ }
251
+ console.error(` 写入 ${paths.manifest}`);
252
+ console.error(` 下一步:modsmith learn ${paths.manifest}`);
253
+ }
254
+ console.log(paths.manifest);
255
+ }
256
+
257
+ async function cmdLearn(args) {
258
+ const manifestPath = args._[1];
259
+ if (!manifestPath) fatal('learn 要给一个 manifest 路径');
260
+ if (!existsSync(manifestPath)) fatal(`找不到 ${manifestPath}`);
261
+
262
+ const { learn, learnStations } = await import('../src/learn.mjs');
263
+
264
+ // --infer:规则优先,不调模型。从清单证据推工作站(L3 的默认路径)。
265
+ if (args.infer === true) {
266
+ const result = await learnStations(manifestPath, {
267
+ evidencePath: typeof args.evidence === 'string' ? args.evidence : undefined,
268
+ force: args.force === true,
269
+ }, { fatal });
270
+ if (!result.ok) fatal('规则推断没得到工作站');
271
+ console.log(manifestPath);
272
+ return;
273
+ }
274
+
275
+ const { askModel, probeModel } = await import('../src/llm.mjs');
276
+
277
+ const probeInfo = await probeModel();
278
+ if (!probeInfo.ok) {
279
+ fatal(`没有可用的模型:${probeInfo.error}
280
+ 换端点用环境变量 MODSMITH_BASE_URL / MODSMITH_MODEL`);
281
+ }
282
+ console.error(`模型:${probeInfo.model} @ ${probeInfo.base}`);
283
+
284
+ let wikiText = '';
285
+ if (typeof args.wiki === 'string') {
286
+ if (existsSync(args.wiki)) wikiText = readFileSync(args.wiki, 'utf8');
287
+ else {
288
+ try {
289
+ const res = await fetch(args.wiki);
290
+ wikiText = await res.text();
291
+ } catch (err) {
292
+ console.error(` 取 wiki 失败(继续,不中断):${err.message}`);
293
+ }
294
+ }
295
+ }
296
+
297
+ // 给了服务器参数就自动上服务器验证;--no-probe 显式关掉
298
+ let probe = null;
299
+ const wantsProbe = args['no-probe'] !== true
300
+ && (args.port !== undefined || args['rcon-password'] !== undefined || args['rcon-port'] !== undefined);
301
+ if (wantsProbe) {
302
+ const { makeStationProbe } = await import('../src/probe.mjs');
303
+ probe = makeStationProbe({
304
+ host: String(args.host ?? '127.0.0.1'),
305
+ port: Number(args.port ?? 25565),
306
+ rconPort: Number(args['rcon-port'] ?? Number(args.port ?? 25565) + 10),
307
+ rconPassword: String(args['rcon-password'] ?? ''),
308
+ mcVersion: String(args['mc-version'] ?? '1.21.1'),
309
+ username: String(args.username ?? 'ModLearn'),
310
+ pack: String(args.pack ?? ''),
311
+ modsDir: typeof args['mods-dir'] === 'string' ? args['mods-dir'] : undefined,
312
+ x: args.x, y: args.y, z: args.z,
313
+ timeout: args.timeout,
314
+ });
315
+ console.error('实测验证:开(站点声明会拿真服务器对一遍)');
316
+ } else {
317
+ console.error('实测验证:关(要验就加 --port 与 --rcon-password)');
318
+ }
319
+
320
+ const result = await learn(manifestPath, {
321
+ ask: (p) => askModel(p),
322
+ probe,
323
+ rounds: Number(args.rounds ?? 3),
324
+ wikiText,
325
+ }, { fatal });
326
+
327
+ if (!result.ok) fatal('learn 没拿到可用结果');
328
+ console.log(manifestPath);
329
+ }
330
+
331
+ async function cmdOnboard(args) {
332
+ const jar = args._[1];
333
+ if (!jar) fatal('onboard 要给一个 jar 路径');
334
+ if (!existsSync(jar)) fatal(`找不到 ${jar}`);
335
+ if (typeof args.pack !== 'string') fatal('onboard 要 --pack <name>(包名,mods/ 下的文件名,不带 .json)');
336
+ const pack = args.pack;
337
+ const modsDir = resolve(typeof args.out === 'string' ? args.out : './mods');
338
+ const { onboardPlan } = await import('../src/onboard.mjs');
339
+
340
+ // 有注册表 dump 就优先用它(秒级);--no-dump 强制回到 calibrate。
341
+ let dump = typeof args.dump === 'string' ? resolve(args.dump) : null;
342
+ if (!dump && args['no-dump'] !== true && typeof args['server-mods'] === 'string') {
343
+ const sm = resolve(args['server-mods']);
344
+ for (const candidate of [
345
+ join(sm, '..', 'config', 'modsmith-dump.json'),
346
+ join(sm, 'config', 'modsmith-dump.json'),
347
+ join(sm, 'modsmith-dump.json'),
348
+ ]) {
349
+ if (existsSync(candidate)) { dump = candidate; break; }
350
+ }
351
+ }
352
+ if (!dump && args['no-dump'] !== true) {
353
+ console.error('! 没有 registry dump:默认 calibrate 可能要跑几小时(逐状态 setblock)。');
354
+ console.error(` 推荐把内置 jar 放进服务端 mods:${DUMP_JAR}`);
355
+ console.error(' 重启服务端,拿 config/modsmith-dump.json,停服删 jar 后再跑同样的命令。');
356
+ console.error(' 没有服务器文件权限时,显式加 --no-dump 走 calibrate。');
357
+ }
358
+ if (dump && !existsSync(dump)) fatal(`找不到 dump:${dump}`);
359
+
360
+ const manifestPath = join(modsDir, `${pack}.json`);
361
+ const idsPath = join(modsDir, `${pack}-ids.json`);
362
+ let hasServerRecipes = false;
363
+ let hasStations = false;
364
+ if (existsSync(manifestPath)) {
365
+ try {
366
+ const m = JSON.parse(readFileSync(manifestPath, 'utf8'));
367
+ hasServerRecipes = (m.recipes ?? []).some((r) => r?._source === 'server');
368
+ // 有非 inferred 的站点 = 人已经审过(或历史上手写过),--resume 时才算完成。
369
+ // 只有 inferred/needsReview 的站点仍要重跑:证据变了机制可能变。
370
+ hasStations = (m.stations ?? []).some((st) => st && st.inferred !== true);
371
+ } catch { /* 清单坏了就当没有,inspect 会重写 */ }
372
+ }
373
+ const probePath = join(modsDir, `${pack}-probe.json`);
374
+ const plan = onboardPlan({
375
+ jar,
376
+ pack,
377
+ modsDir,
378
+ dump,
379
+ server: {
380
+ host: args.host, port: args.port, rconPort: args['rcon-port'],
381
+ rconPassword: args['rcon-password'], mcVersion: args['mc-version'],
382
+ serverMods: args['server-mods'], x: args.x, y: args.y, z: args.z,
383
+ },
384
+ skip: typeof args.skip === 'string' ? args.skip.split(',').map((s) => s.trim()).filter(Boolean) : [],
385
+ resume: args.resume === true,
386
+ artifacts: {
387
+ manifest: existsSync(manifestPath),
388
+ ids: existsSync(idsPath),
389
+ probe: existsSync(probePath),
390
+ stations: hasStations,
391
+ serverRecipes: hasServerRecipes,
392
+ },
393
+ dryRecipes: args['dry-recipes'] === true,
394
+ serializers: typeof args.serializers === 'string' ? resolve(args.serializers) : null,
395
+ noBuild: args['no-build'] === true,
396
+ });
397
+
398
+ if (args.plan === true) {
399
+ for (const s of plan.steps) {
400
+ console.log(`${s.done ? '✓' : s.blocked ? '✗' : '·'} ${s.id.padEnd(9)} ${s.title}${s.reason ? ` (${s.reason})` : ''}`);
401
+ }
402
+ console.log(`manifest: ${plan.manifestPath}\nids: ${plan.idsPath}`);
403
+ return;
404
+ }
405
+
406
+ for (const s of plan.steps) {
407
+ if (s.done) { console.error(`✓ ${s.id}:产物已存在,跳过(--resume)`); continue; }
408
+ if (s.blocked) fatal(`${s.id} 无法执行:${s.reason}`);
409
+ if (s.reason) console.error(` - ${s.id}:${s.reason}`);
410
+ console.error(`▶ ${s.id}:${s.title}`);
411
+ await runCommand(parseArgs(s.argv));
412
+ }
413
+ console.error('✓ onboard 全部步骤完成。侧车 jar 与站点配置在 sidecars/ 下——装不装进服务器由你决定(绝不自动装)。');
414
+ }
415
+
416
+ async function cmdConformance(args) {
417
+ const manifestPath = args._[1] ? resolve(args._[1]) : null;
418
+ if (!manifestPath) fatal('conformance 要给一个 manifest.json');
419
+ if (!existsSync(manifestPath)) fatal(`找不到 ${manifestPath}`);
420
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
421
+ const jsonPath = typeof args.json === 'string' ? resolve(args.json) : null;
422
+ const mcv = typeof args['mc-version'] === 'string' ? args['mc-version'] : '1.21.1';
423
+ const serverMods = typeof args['server-mods'] === 'string' ? resolve(args['server-mods']) : undefined;
424
+ const manifestOnly = args['manifest-only'] === true;
425
+
426
+ let ids = null;
427
+ if (!manifestOnly) {
428
+ const idsPath = typeof args.ids === 'string'
429
+ ? resolve(args.ids)
430
+ : manifestPath.replace(/\.json$/, '-ids.json');
431
+ if (existsSync(idsPath)) ids = JSON.parse(readFileSync(idsPath, 'utf8'));
432
+ else if (typeof args.ids === 'string') fatal(`找不到 ${idsPath}`);
433
+ else console.error(`! 没找到 ${idsPath}:只查 manifest,ids/registry 行为未验`);
434
+ }
435
+
436
+ let registry = null;
437
+ // --live 时让真实的 bot 去注入/检查,避免同进程的离线 registry 先污染
438
+ // prismarine-registry 的 minecraft-data 缓存。
439
+ if (ids && args.live !== true) {
440
+ const mod = await import('prismarine-registry');
441
+ const create = mod.default ?? mod;
442
+ registry = create(mcv);
443
+ }
444
+ const { runConformance, summarize } = await import('../src/conformance.mjs');
445
+ const report = runConformance({ manifest, ids, registry, serverMods, mcv });
446
+ if (args.live === true) {
447
+ if (!ids) fatal('--live 需要 ids(先 import-dump 或 calibrate)');
448
+ try {
449
+ const { liveConformance } = await import('../src/conformance-live.mjs');
450
+ const live = await liveConformance({
451
+ manifest,
452
+ ids,
453
+ host: typeof args.host === 'string' ? args.host : '127.0.0.1',
454
+ port: Number(args.port ?? 25565),
455
+ username: typeof args.username === 'string' ? args.username : 'ModConform',
456
+ version: mcv,
457
+ rcon: typeof args['rcon-password'] === 'string'
458
+ ? {
459
+ host: typeof args.host === 'string' ? args.host : '127.0.0.1',
460
+ port: Number(args['rcon-port'] ?? 25575),
461
+ password: args['rcon-password'],
462
+ x: Number(args.x ?? 400),
463
+ y: Number(args.y ?? 100),
464
+ z: Number(args.z ?? 400),
465
+ }
466
+ : null,
467
+ });
468
+ report.live = live.bot;
469
+ report.registry = live.counts;
470
+ report.checks.push(...live.checks);
471
+ } catch (err) {
472
+ report.checks.push({
473
+ id: 'L1', level: 'fail', subject: 'live', message: `真机消费失败:${err.message}`,
474
+ });
475
+ }
476
+ }
477
+ report.summary = summarize(report.checks);
478
+ if (jsonPath) {
479
+ writeFileSync(jsonPath, `${JSON.stringify(report, null, 1)}\n`);
480
+ console.error(`报告:${jsonPath}`);
481
+ }
482
+ const { pass, warn, fail } = report.summary;
483
+ console.log(`[conformance] ${report.pack}: 通过 ${pass},警告 ${warn},失败 ${fail}`);
484
+ if (report.live) {
485
+ const p = report.live.position;
486
+ console.log(`[live] ${report.live.username} @ ${report.live.version} ${report.live.dimension}`
487
+ + (p ? ` (${p.x}, ${p.y}, ${p.z})` : '')
488
+ + (report.registry ? `;注册表 ${report.registry.blocks} 方块 / ${report.registry.entities} 实体` : ''));
489
+ }
490
+ for (const c of report.checks.filter((x) => x.level !== 'pass')) {
491
+ console.log(`${c.level === 'fail' ? '✗' : '~'} ${c.id} ${c.subject}: ${c.message}`);
492
+ }
493
+ if (fail > 0 || (args.strict === true && warn > 0)) {
494
+ console.error('✗ conformance 未通过:先修上面的 fail(onboard 会停在这里)');
495
+ process.exit(1);
496
+ }
497
+ }
498
+
499
+ /** 命令分发(main 与 onboard 的步骤执行共用;argv[0] 是命令名)。 */
500
+ async function runCommand(args) {
501
+ const cmd = args._[0];
502
+ switch (cmd) {
503
+ case 'dump-jar': {
504
+ if (!existsSync(DUMP_JAR)) fatal(`包里没有 dump jar:${DUMP_JAR}`);
505
+ console.log(DUMP_JAR);
506
+ return;
507
+ }
508
+ case 'inspect': return cmdInspect(args);
509
+ case 'conformance': return cmdConformance(args);
510
+ case 'import-dump': {
511
+ const dump = args._[1];
512
+ const manifest = args.manifest;
513
+ if (!dump || !manifest) fatal('import-dump 要 <dump.json> --manifest <manifest.json> [--ids <out.json>] [--server-mods <dir>]');
514
+ const mod = await import('../src/import-dump.mjs');
515
+ const result = mod.importDump(resolve(String(dump)), resolve(String(manifest)), {
516
+ idsPath: typeof args.ids === 'string' ? resolve(args.ids) : undefined,
517
+ serverMods: typeof args['server-mods'] === 'string' ? resolve(args['server-mods']) : undefined,
518
+ mcv: args['mc-version'],
519
+ });
520
+ console.log(result.out);
521
+ if (result.failed.length > 0) {
522
+ console.error(`! ${result.failed.length} 项被剔除(dump 里没有/落在 vanilla 区间/重复),详见 ids.failed`);
523
+ }
524
+ if (!result.hasEntities) {
525
+ console.error('! 这份 dump 没有 entities(dump mod 0.1.1 及更早):模组实体名仍会被认成 unknown;'
526
+ + '换 templates/registry-dump/modsmith-dump-0.1.2.jar 重新 dump 一次');
527
+ }
528
+ return;
529
+ }
530
+ case 'recipes': {
531
+ const mod = await import('../src/recipes-cli.mjs');
532
+ return mod.default(args, { fatal });
533
+ }
534
+ case 'probe':
535
+ case 'calibrate':
536
+ case 'verify':
537
+ case 'merge': {
538
+ // 这几个要连服务器,模块在用到时才载入(inspect 不该背这个依赖)
539
+ const mod = await import(`../src/${cmd}.mjs`);
540
+ return mod.default(args, { fatal });
541
+ }
542
+ case 'learn': {
543
+ return cmdLearn(args);
544
+ }
545
+ case 'sidecar': {
546
+ const mod = await import('../src/sidecar.mjs');
547
+ return mod.default(args, { fatal });
548
+ }
549
+ case 'onboard': {
550
+ return cmdOnboard(args);
551
+ }
552
+ default:
553
+ fatal(`不认识的命令:${cmd}(用 --help 看用法)`);
554
+ }
555
+ }
556
+
557
+ async function main() {
558
+ const argv = process.argv.slice(2);
559
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
560
+ console.log(USAGE);
561
+ return;
562
+ }
563
+ if (argv[0] === '--version' || argv[0] === '-V') {
564
+ console.log(VERSION);
565
+ return;
566
+ }
567
+ return runCommand(parseArgs(argv));
568
+ }
569
+
570
+ void main().catch((e) => {
571
+ console.error(e?.stack || String(e));
572
+ process.exit(1);
573
+ });