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
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 方块碰撞/工具的唯一一份推导规则。
3
+ *
4
+ * 三个消费者必须共用:
5
+ * - `inspect`:从模组 jar 的模型 parent 推 manifest 的 box/solid;
6
+ * - `cortico-bridge`:把 manifest + 实测 id 注入 prismarine registry;
7
+ * - `conformance`:接包验收,检查「声明的每个 state 真的有形状/能挖」。
8
+ *
9
+ * 以前 inspect 和 host 各写一份,于是 TF 的 `canopy_planks` 在清单里是实心、
10
+ * 注入后却是空气。规则只留这一份,测试也只钉这一份。
11
+ */
12
+
13
+ /** 这些方块不是"挖"的对象;其余模组方块默认都能挖(作物/花没有碰撞箱,但能挖)。 */
14
+ export const NOT_DIGGABLE = new Set([
15
+ 'air', 'cave_air', 'void_air', 'water', 'lava', 'bedrock', 'barrier', 'light',
16
+ 'command_block', 'chain_command_block', 'repeating_command_block', 'structure_block',
17
+ 'structure_void', 'jigsaw', 'end_portal', 'end_portal_frame', 'nether_portal',
18
+ 'moving_piston', 'reinforced_deepslate',
19
+ ]);
20
+
21
+ export function isDiggable(name) {
22
+ return !NOT_DIGGABLE.has(String(name).split(':')[1] ?? name);
23
+ }
24
+
25
+ /** 从 elements 里算整体包围盒(0..1 归一化,跟 prismarine 的 box 一致)。 */
26
+ export function boxFromElements(elements) {
27
+ if (!Array.isArray(elements) || elements.length === 0) return null;
28
+ let x0 = Infinity, y0 = Infinity, z0 = Infinity;
29
+ let x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
30
+ for (const el of elements) {
31
+ const [a, b] = el.from ?? [0, 0, 0];
32
+ const [c, d, e] = el.to ?? [16, 16, 16];
33
+ x0 = Math.min(x0, a); y0 = Math.min(y0, b); z0 = Math.min(z0, c);
34
+ x1 = Math.max(x1, c); y1 = Math.max(y1, d); z1 = Math.max(z1, e);
35
+ }
36
+ if (!Number.isFinite(x0)) return null;
37
+ return [x0 / 16, y0 / 16, z0 / 16, x1 / 16, y1 / 16, z1 / 16];
38
+ }
39
+
40
+ /**
41
+ * 原版 parent 在服务器 jar 里,模组只写 `parent: minecraft:block/cube_all`。
42
+ * 这些 parent 的名字就够定碰撞盒;没有它,整块木板会被当成空气(TF 实测:
43
+ * canopy_planks 变成 solid:false,机器人直接穿地板)。
44
+ */
45
+ const VANILLA_PARENT_BOX = {
46
+ 'block/block': [0, 0, 0, 1, 1, 1],
47
+ 'builtin/entity': [0, 0, 0, 1, 1, 1],
48
+ 'block/chest': [0, 0, 0, 1, 1, 1],
49
+ 'block/bookshelf': [0, 0, 0, 1, 1, 1],
50
+ 'block/chiseled_bookshelf': [0, 0, 0, 1, 1, 1],
51
+ 'block/cube': [0, 0, 0, 1, 1, 1],
52
+ 'block/cube_all': [0, 0, 0, 1, 1, 1],
53
+ 'block/cube_bottom_top': [0, 0, 0, 1, 1, 1],
54
+ 'block/cube_column': [0, 0, 0, 1, 1, 1],
55
+ 'block/cube_column_horizontal': [0, 0, 0, 1, 1, 1],
56
+ 'block/cube_directional': [0, 0, 0, 1, 1, 1],
57
+ 'block/cube_mirrored': [0, 0, 0, 1, 1, 1],
58
+ 'block/cube_mirrored_all': [0, 0, 0, 1, 1, 1],
59
+ 'block/orientable': [0, 0, 0, 1, 1, 1],
60
+ 'block/orientable_with_bottom': [0, 0, 0, 1, 1, 1],
61
+ 'block/orientable_vertical': [0, 0, 0, 1, 1, 1],
62
+ 'block/leaves': [0, 0, 0, 1, 1, 1],
63
+ 'block/stairs': [0, 0, 0, 1, 0.5, 1],
64
+ 'block/slab': [0, 0, 0, 1, 0.5, 1],
65
+ 'block/slab_top': [0, 0.5, 0, 1, 1, 1],
66
+ 'block/fence_post': [0.375, 0, 0.375, 0.625, 1, 0.625],
67
+ 'block/fence_side': [0.375, 0, 0.375, 0.625, 1, 0.625],
68
+ 'block/wall_post': [0.25, 0, 0.25, 0.75, 1, 0.75],
69
+ 'block/wall_side': [0.25, 0, 0.25, 0.75, 1, 0.75],
70
+ 'block/trapdoor_bottom': [0, 0, 0, 1, 0.1875, 1],
71
+ 'block/trapdoor_top': [0, 0, 0, 1, 0.1875, 1],
72
+ 'block/template_orientable_trapdoor_bottom': [0, 0, 0, 1, 0.1875, 1],
73
+ 'block/template_orientable_trapdoor_top': [0, 0, 0, 1, 0.1875, 1],
74
+ 'block/template_orientable_trapdoor_open': [0, 0, 0, 1, 1, 0.1875],
75
+ 'block/template_fence_gate': [0, 0, 0, 1, 1, 0.375],
76
+ 'block/template_fence_gate_open': [0, 0, 0, 1, 1, 0.375],
77
+ 'block/template_fence_gate_wall': [0, 0, 0, 1, 1, 0.375],
78
+ 'block/template_fence_gate_wall_open': [0, 0, 0, 1, 1, 0.375],
79
+ 'block/door_bottom_left': [0, 0, 0, 1, 1, 0.1875],
80
+ 'block/door_bottom_right': [0, 0, 0, 1, 1, 0.1875],
81
+ 'block/pane_post': [0.4375, 0, 0.4375, 0.5625, 1, 0.5625],
82
+ 'block/pane_side': [0.4375, 0, 0.4375, 0.5625, 1, 0.5625],
83
+ 'block/template_glass_pane_post': [0.4375, 0, 0.4375, 0.5625, 1, 0.5625],
84
+ 'block/template_glass_pane_side': [0.4375, 0, 0.4375, 0.5625, 1, 0.5625],
85
+ 'block/template_glass_pane_side_alt': [0.4375, 0, 0.4375, 0.5625, 1, 0.5625],
86
+ 'block/template_glass_pane_noside': [0.4375, 0, 0.4375, 0.5625, 1, 0.5625],
87
+ 'block/template_glass_pane_noside_alt': [0.4375, 0, 0.4375, 0.5625, 1, 0.5625],
88
+ 'block/carpet': [0, 0, 0, 1, 0.0625, 1],
89
+ 'block/pressure_plate_up': [0, 0, 0, 1, 0.0625, 1],
90
+ 'block/pressure_plate_down': [0, 0, 0, 1, 0.0625, 1],
91
+ };
92
+
93
+ export function vanillaParentBox(ref) {
94
+ const name = String(ref ?? '').replace(/^minecraft:/, '');
95
+ return VANILLA_PARENT_BOX[name] ?? null;
96
+ }
97
+
98
+ /** 模型链走到底:先找 elements;没有就看原版 parent 名推一个保守碰撞盒。 */
99
+ export function boxForRef(materials, ref, depth = 0) {
100
+ if (!ref || depth > 8) return null;
101
+ const name = ref.includes(':') ? ref.split(':')[1] : ref;
102
+ const ns = ref.includes(':') ? ref.split(':')[0] : null;
103
+ const candidates = ns ? [`assets/${ns}/models/${name}.json`] : [`assets/__NS__/models/${name}.json`];
104
+ let json = null;
105
+ for (const c of candidates) {
106
+ json = materials.readJson(c);
107
+ if (json) break;
108
+ }
109
+ if (!json) return vanillaParentBox(ref);
110
+ const own = boxFromElements(json.elements);
111
+ if (own) return own;
112
+ if (json.parent) {
113
+ const inherited = boxForRef(materials, json.parent, depth + 1);
114
+ if (inherited) return inherited;
115
+ }
116
+ return vanillaParentBox(ref);
117
+ }
118
+
119
+ /**
120
+ * 非整块方块的碰撞形状不能统一用一个 box:楼梯的上/下半、台阶的朝向都不同。
121
+ * 原版 registry 已经带每种 state 的 shape id;按名字给模组方块找同族模板,
122
+ * 再用 state 属性映射到模板的具体 state。找不到模板就退回清单里的 box。
123
+ */
124
+ export function shapeTemplateOf(name) {
125
+ const short = name.split(':')[1] ?? name;
126
+ if (/_trapdoor/.test(short)) return 'oak_trapdoor';
127
+ if (/_fence_gate/.test(short)) return 'oak_fence_gate';
128
+ if (/_fence/.test(short)) return 'oak_fence';
129
+ if (/_stairs/.test(short)) return 'oak_stairs';
130
+ if (/_slab/.test(short)) return 'oak_slab';
131
+ if (/_wall$/.test(short)) return 'cobblestone_wall';
132
+ if (/_door/.test(short)) return 'oak_door';
133
+ if (/_pane/.test(short)) return 'glass_pane';
134
+ if (/_carpet/.test(short)) return 'white_carpet';
135
+ if (/_button/.test(short)) return 'oak_button';
136
+ if (/_pressure_plate/.test(short)) return 'oak_pressure_plate';
137
+ return null;
138
+ }
139
+
140
+ /** 把模组 state 的属性映射到模板 state 的 shape id;映射不了返回 null。 */
141
+ export function templateShapeId(reg, templateName, props) {
142
+ const tpl = reg.blocksByName?.[templateName];
143
+ const ids = reg.blockCollisionShapes?.blocks?.[templateName];
144
+ if (!Array.isArray(tpl?.states) || !Array.isArray(ids)) return null;
145
+ // 状态索引是**最后一个属性最快变化**(与 registry 的 stateId 顺序一致):
146
+ // index = ((type * 2) + waterlogged),不是 waterlogged * 3 + type。
147
+ // 之前按第一个属性最快算,oak_slab 的 top 拿到了下半砖形状、bottom 拿到上半砖形状
148
+ // (楼梯同理)——形状非空,所以只查"非空"的检查全放过了它。
149
+ let index = 0;
150
+ for (const desc of tpl.states) {
151
+ const raw = props?.[desc.name];
152
+ let value;
153
+ if (Array.isArray(desc.values)) {
154
+ value = desc.values.indexOf(String(raw));
155
+ if (value < 0) return null;
156
+ } else if (desc.type === 'bool') {
157
+ value = String(raw) === 'true' ? 1 : 0;
158
+ } else {
159
+ if (raw === undefined) return null;
160
+ value = Number(raw);
161
+ if (!Number.isInteger(value) || value < 0 || value >= desc.num_values) return null;
162
+ }
163
+ index = index * desc.num_values + value;
164
+ }
165
+ return ids[index] ?? null;
166
+ }
167
+
168
+ /** 从方块 tags 推 harvestTools:矿石/木头这类要工具,作物/植物用手就行。 */
169
+ export function harvestToolsFor(reg, tags) {
170
+ const template = tags.includes('mineable/pickaxe') ? 'stone'
171
+ : tags.includes('mineable/axe') ? 'oak_log'
172
+ : tags.includes('mineable/shovel') ? 'dirt'
173
+ : tags.includes('mineable/hoe') ? 'oak_leaves'
174
+ : null;
175
+ return template ? (reg.blocksByName?.[template]?.harvestTools ?? null) : null;
176
+ }
package/src/calibrate.mjs CHANGED
@@ -126,6 +126,7 @@ export default async function cmdCalibrate(args, io) {
126
126
  try { await cmd(`setblock ${bx} ${by} ${bz} minecraft:air`); } catch { /* */ }
127
127
  try { await cmd(`forceload remove ${bx - 16} ${bz - 16} ${bx + 16} ${bz + 16}`); } catch { /* */ }
128
128
  try { bot?.quit(); } catch { /* */ }
129
+ cmd.close();
129
130
  };
130
131
 
131
132
  try {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * 真机参考消费:连一台测试/生产服务器,用真实 mineflayer bot 消费 manifest+ids。
3
+ *
4
+ * 只读:不 setblock、不 summon、不聊天。它验证的是「一个普通 mineflayer bot 在
5
+ * inject_allowed 前注入后,真的能拿到方块/形状/实体」——比离线模拟更接近宿主。
6
+ * 需要写世界的抽样验证(setblock/召唤)留给人工测试服,不在这里做。
7
+ */
8
+ import mineflayer from 'mineflayer';
9
+ import { Vec3 } from 'vec3';
10
+ import { installModRegistry, installMineflayerFixes } from './cortico-bridge.mjs';
11
+ import { checkRegistry } from './conformance.mjs';
12
+ import { makeRcon } from './rcon.mjs';
13
+
14
+ function waitSpawn(bot, timeoutMs) {
15
+ return new Promise((resolve, reject) => {
16
+ let timer = null;
17
+ const done = (err) => {
18
+ if (timer) clearTimeout(timer);
19
+ bot.removeListener('spawn', onSpawn);
20
+ bot.removeListener('error', onError);
21
+ bot.removeListener('end', onEnd);
22
+ if (err) reject(err);
23
+ else resolve();
24
+ };
25
+ const onSpawn = () => done(null);
26
+ const onError = (err) => done(err instanceof Error ? err : new Error(String(err)));
27
+ const onEnd = (reason) => done(new Error(`连接在 spawn 前结束:${reason}`));
28
+ bot.once('spawn', onSpawn);
29
+ bot.once('error', onError);
30
+ bot.once('end', onEnd);
31
+ timer = setTimeout(() => done(new Error(`等 spawn 超时(${timeoutMs}ms)`)), timeoutMs);
32
+ });
33
+ }
34
+
35
+ function sampleStates(manifest) {
36
+ const out = [];
37
+ for (const [name, spec] of Object.entries(manifest.blocks ?? {})) {
38
+ const states = spec?.states ?? [];
39
+ if (states.length === 0) continue;
40
+ const short = name.split(':')[1] ?? name;
41
+ if (/_stairs/.test(short)) {
42
+ for (const half of ['bottom', 'top']) {
43
+ const state = states.find((s) => s.half === half);
44
+ if (state) out.push({ name, props: state, why: `stairs ${half}` });
45
+ }
46
+ } else if (/_slab/.test(short)) {
47
+ for (const type of ['bottom', 'top']) {
48
+ const state = states.find((s) => s.type === type);
49
+ if (state) out.push({ name, props: state, why: `slab ${type}` });
50
+ }
51
+ } else if (spec.solid !== false && Array.isArray(spec.box)) {
52
+ out.push({ name, props: states[0], why: 'solid full block' });
53
+ }
54
+ if (out.length >= 8) break;
55
+ }
56
+ return out.slice(0, 8);
57
+ }
58
+
59
+ function blockSpec(name, props) {
60
+ const entries = Object.entries(props ?? {}).filter(([, v]) => v !== undefined);
61
+ if (entries.length === 0) return name;
62
+ return `${name}[${entries.map(([k, v]) => `${k}=${v}`).join(',')}]`;
63
+ }
64
+
65
+ /**
66
+ * 在固定测试格 setblock + tp,读真实 bot 的 blockAt/shapes。只应在测试服使用。
67
+ * 返回 L2/L3 检查;不碰生产服。
68
+ */
69
+ export async function sampleWorld({ bot, manifest, ids, rcon, x = 400, y = 100, z = 400, timeoutMs = 20_000 }) {
70
+ const checks = [];
71
+ const cmd = await makeRcon(rcon.host ?? '127.0.0.1', rcon.port, rcon.password);
72
+ const username = bot.username;
73
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
74
+ const at = new Vec3(x, y, z);
75
+ try {
76
+ await cmd(`forceload add ${x - 2} ${z - 2} ${x + 2} ${z + 2}`);
77
+ await cmd(`tp ${username} ${x + 0.5} ${y + 1} ${z + 0.5}`);
78
+ await sleep(600);
79
+ const signatures = new Map();
80
+ for (const sample of sampleStates(manifest)) {
81
+ const spec = blockSpec(sample.name, sample.props);
82
+ const placed = await cmd(`setblock ${x} ${y} ${z} ${spec} replace`);
83
+ // setblock 到客户端区块更新有延迟。只等固定 250ms 时读到的是上一态(名字还一样,
84
+ // 台阶/楼梯的上下两态就会看起来"形状相同")。先按这一态的**判别属性**等。
85
+ // 只按 half/type 比:楼梯的 shape 会被邻居校正(inner_left→straight),
86
+ // 不能要求完整 props 命中,否则会把服务端正常行为误报成 id 错。
87
+ const key = sample.why.startsWith('stairs ') ? ['half']
88
+ : sample.why.startsWith('slab ') ? ['type'] : [];
89
+ const expected = (ids.blocks?.[sample.name] ?? [])
90
+ .filter((m) => key.every((k) => String(m.props?.[k]) === String(sample.props?.[k])))
91
+ .map((m) => m.stateId);
92
+ await sleep(250);
93
+ let block = bot.blockAt(at);
94
+ const deadline = Date.now() + 2000;
95
+ while (Date.now() < deadline && block
96
+ && expected.length > 0 && !expected.includes(block.stateId)) {
97
+ await sleep(100);
98
+ block = bot.blockAt(at);
99
+ }
100
+ const okName = block?.name === sample.name;
101
+ const okState = expected.length === 0 || expected.includes(block?.stateId);
102
+ const shape = block?.shapes ?? [];
103
+ signatures.set(`${sample.name}\u0000${sample.why}`, JSON.stringify(shape));
104
+ checks.push({
105
+ id: 'L2', level: okName && okState && shape.length > 0 ? 'pass' : 'fail',
106
+ subject: sample.name,
107
+ message: !okName
108
+ ? `${sample.why}:服务端/客户端名字对不上(${block?.name ?? 'null'})`
109
+ : !okState
110
+ ? `${sample.why}:stateId ${block?.stateId} 不在 ids 的 ${expected.slice(0, 4).join('/')} 里`
111
+ : `${sample.why}:${placed.includes('Changed') ? '已放置' : placed},shape ${shape.length}`,
112
+ });
113
+ await cmd(`setblock ${x} ${y} ${z} air replace`);
114
+ }
115
+ // 非空还不够:上下两态若被记成同一个 stateId,玩家看到的是半砖/楼梯永远一个朝向,
116
+ // 而 shape 长度照样 >0。这里把上下两态的形状逐字节比一次(实测 Supplementaries
117
+ // 的台阶/楼梯靠这条才能区分"读对了"和"都读成默认态")。
118
+ for (const [name, kind] of Object.entries({
119
+ 'stairs bottom': 'stairs top',
120
+ 'slab bottom': 'slab top',
121
+ })) {
122
+ for (const [key, low] of signatures) {
123
+ const [subject, why] = key.split('\u0000');
124
+ if (why !== name) continue;
125
+ const high = signatures.get(`${subject}\u0000${kind}`);
126
+ if (high === undefined) continue;
127
+ checks.push({
128
+ id: 'L2', level: low !== high ? 'pass' : 'fail', subject,
129
+ message: low !== high
130
+ ? `${name.split(' ')[0]} 的上下两态形状不同`
131
+ : `${name.split(' ')[0]} 的上下两态形状相同(stateId 映射可疑)`,
132
+ });
133
+ }
134
+ }
135
+
136
+ const entityName = Object.entries(ids.entities ?? {}).find(([, e]) => !e?.boss)?.[0];
137
+ if (entityName) {
138
+ await cmd(`summon ${entityName} ${x + 3} ${y + 1} ${z}`);
139
+ await sleep(800);
140
+ const seen = Object.values(bot.entities ?? {}).some((e) => e?.name === entityName);
141
+ checks.push({
142
+ id: 'L3', level: seen ? 'pass' : 'fail', subject: entityName,
143
+ message: seen ? '召唤后 bot.entities 认得出名字' : '召唤后 bot.entities 里找不到这个名字',
144
+ });
145
+ await cmd(`kill @e[type=${entityName}]`);
146
+ } else {
147
+ checks.push({ id: 'L3', level: 'warn', subject: 'entities', message: 'ids 里没有非 boss 实体可抽样' });
148
+ }
149
+ } finally {
150
+ await cmd(`setblock ${x} ${y} ${z} air replace`).catch(() => undefined);
151
+ await cmd(`forceload remove ${x - 2} ${z - 2} ${x + 2} ${z + 2}`).catch(() => undefined);
152
+ cmd.close();
153
+ }
154
+ return checks;
155
+ }
156
+
157
+ /**
158
+ * @param {{
159
+ * manifest: object, ids: object,
160
+ * host?: string, port?: number, username?: string, version?: string,
161
+ * timeoutMs?: number,
162
+ * rcon?: { port: number, password: string, x?: number, y?: number, z?: number } | null,
163
+ * }} opts
164
+ */
165
+ export async function liveConformance(opts) {
166
+ const {
167
+ manifest, ids,
168
+ host = '127.0.0.1', port = 25565, username = 'ModConform', version = '1.21.1',
169
+ timeoutMs = 60_000, rcon = null,
170
+ } = opts;
171
+ let bot = null;
172
+ try {
173
+ bot = mineflayer.createBot({
174
+ host, port, username, version, auth: 'offline', viewDistance: 2,
175
+ });
176
+ // 与宿主一样:必须在 inject_allowed 之前注入静态注册表。
177
+ const counts = installModRegistry(bot, { manifest, ids });
178
+ // prismarine-registry 会复用 minecraft-data 缓存;同进程里先跑过离线验收时,
179
+ // 注入器可能认为实体已经在了而返回 0。以真实 registry 里的条目为准。
180
+ counts.entities = Object.keys(ids.entities ?? {})
181
+ .filter((name) => Boolean(bot.registry?.entitiesByName?.[name])).length;
182
+ // 只读验证不依赖命令树/实体元数据;大 mod(TF)这两类包会让 protodef 整包错位。
183
+ installMineflayerFixes(bot, { extraDrop: ['entity_metadata', 'declare_commands'], dropCommands: true });
184
+ await waitSpawn(bot, timeoutMs);
185
+ const checks = checkRegistry(manifest, ids, bot.registry, { installed: true });
186
+ if (rcon?.password) {
187
+ checks.push(...await sampleWorld({ bot, manifest, ids, rcon, x: rcon.x, y: rcon.y, z: rcon.z }));
188
+ }
189
+ return {
190
+ counts,
191
+ checks,
192
+ bot: {
193
+ username: bot.username,
194
+ version: bot.version,
195
+ dimension: String(bot.game?.dimension ?? 'unknown'),
196
+ position: bot.entity?.position
197
+ ? {
198
+ x: Math.round(bot.entity.position.x),
199
+ y: Math.round(bot.entity.position.y),
200
+ z: Math.round(bot.entity.position.z),
201
+ }
202
+ : null,
203
+ },
204
+ };
205
+ } finally {
206
+ try { bot?.quit('conformance done'); } catch { /* 已经断开 */ }
207
+ }
208
+ }