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.
- package/AGENTS.md +6 -0
- package/README.md +131 -20
- package/bin/modsmith.mjs +573 -405
- package/docs/conformance/README.md +62 -0
- package/docs/conformance/farmers-delight-1.21.1-3.3.6.json +209 -0
- package/docs/conformance/farmers-delight-dev-live.json +321 -0
- package/docs/conformance/moredelight-26.04.30-1.21.json +47 -0
- package/docs/conformance/supplementaries-1.21.1-4.0.2.json +227 -0
- package/docs/conformance/twilightforest-1.21.1-4.8.796.json +317 -0
- package/package.json +16 -3
- package/schema/manifest.schema.json +7 -0
- package/sidesheets/farmers-delight-3.3.6.crops.json +42 -42
- package/src/block-shapes.mjs +176 -0
- package/src/calibrate.mjs +1 -0
- package/src/conformance-live.mjs +208 -0
- package/src/conformance.mjs +366 -0
- package/src/cortico-bridge.mjs +397 -325
- package/src/crops.mjs +246 -227
- package/src/import-dump.mjs +139 -0
- package/src/index.mjs +3 -1
- package/src/inspect.mjs +466 -461
- package/src/manifest-io.mjs +1 -1
- package/src/onboard.mjs +21 -3
- package/src/probe.mjs +325 -316
- package/src/rcon.mjs +14 -8
- package/src/server-fingerprint.mjs +5 -5
- package/src/stations.mjs +22 -1
- package/src/validate.mjs +231 -223
- package/src/verify.mjs +18 -10
- package/templates/reference-bot/README.md +22 -0
- package/templates/reference-bot/index.mjs +59 -0
- package/templates/registry-dump/README.md +48 -0
- package/templates/registry-dump/build.gradle +39 -0
- package/templates/registry-dump/gradle.properties +2 -0
- package/templates/registry-dump/modsmith-dump-0.1.2.jar +0 -0
- package/templates/registry-dump/settings.gradle +9 -0
- package/templates/registry-dump/src/main/java/dev/modsmith/dump/RegistryDumpMod.java +98 -0
- package/templates/registry-dump/src/main/resources/fabric.mod.json +18 -0
- package/types/index.d.ts +174 -135
package/src/rcon.mjs
CHANGED
|
@@ -49,15 +49,21 @@ export function makeRcon(host, port, password) {
|
|
|
49
49
|
s.on('close', () => fail(new Error('RCON 连接断了')));
|
|
50
50
|
s.on('connect', () => {
|
|
51
51
|
send(3, password);
|
|
52
|
-
waiters.push(() =>
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
waiters.push(() => {
|
|
53
|
+
const api = (cmd) => new Promise((res, rej) => {
|
|
54
|
+
const timer = setTimeout(() => rej(new Error(`RCON 命令 15s 无响应:${cmd.slice(0, 60)}`)), 15000);
|
|
55
|
+
send(2, cmd);
|
|
56
|
+
waiters.push((value, err) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (err) rej(err);
|
|
59
|
+
else res(value);
|
|
60
|
+
});
|
|
59
61
|
});
|
|
60
|
-
|
|
62
|
+
// CLI 一个进程会串好几步(onboard):socket 不关,事件循环就一直挂着,
|
|
63
|
+
// 所有步骤完成后进程也不退出(实测)。
|
|
64
|
+
api.close = () => { try { s.end(); } catch { /* 已经断开 */ } };
|
|
65
|
+
resolve(api);
|
|
66
|
+
});
|
|
61
67
|
});
|
|
62
68
|
});
|
|
63
69
|
}
|
|
@@ -22,12 +22,12 @@ export function fingerprintOf(files) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* 不参与指纹的文件:
|
|
26
|
+
* - `modsmith-sidecar-*`:只注册命令、不注册任何方块/物品,不可能让 id 位移;
|
|
27
|
+
* - `modsmith-dump-*`:只读注册表写 JSON,同样不注册内容。把它算进指纹会让
|
|
28
|
+
* “装了 dump → ids 立刻过期”,而 dump 本来就是一次性测量工具,不该成为运行时依赖。
|
|
29
29
|
*/
|
|
30
|
-
const IGNORED = [/^modsmith-sidecar.*\.jar$/i];
|
|
30
|
+
const IGNORED = [/^modsmith-sidecar.*\.jar$/i, /^modsmith-dump.*\.jar$/i];
|
|
31
31
|
|
|
32
32
|
/** 读一个 mods 目录(顶层文件),算出指纹。目录不存在/读不了会抛,由调用方决定怎么报。 */
|
|
33
33
|
export function fingerprintDir(dir) {
|
package/src/stations.mjs
CHANGED
|
@@ -19,6 +19,26 @@ const VANILLA_STATION_TYPES = new Set([
|
|
|
19
19
|
'campfire_cooking', 'stonecutting', 'smithing_transform', 'smithing_trim',
|
|
20
20
|
]);
|
|
21
21
|
|
|
22
|
+
/** verify --stations 真的会执行的三种机制。 */
|
|
23
|
+
export const STATION_EXECUTORS = new Set(['sidecar-recipe', 'two-step-use', 'place-and-wait']);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 站点为什么"已知但未验"(不是失败):
|
|
27
|
+
* - 机制没有执行器
|
|
28
|
+
* - sidecar-recipe 的命令还没写回(没编译/没安装侧车)
|
|
29
|
+
* 缺 recipeType 不在这里——那是清单数据错误,调用方按失败报。
|
|
30
|
+
*/
|
|
31
|
+
export function stationSkipReason(station) {
|
|
32
|
+
if (!station?.recipeType) return null;
|
|
33
|
+
if (!STATION_EXECUTORS.has(station.kind)) {
|
|
34
|
+
return `机制 ${station.kind} 还没有执行器(已知但未验)`;
|
|
35
|
+
}
|
|
36
|
+
if (station.kind === 'sidecar-recipe' && !station.command) {
|
|
37
|
+
return '侧车命令没有写回(未编译/未安装):已知但未验';
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
/** 配方类型 → 可能的方块名词干(类型词干之外再兜一层别名)。 */
|
|
23
43
|
const STEM_ALIASES = {
|
|
24
44
|
cooking: ['cooking_pot', 'pot', 'oven', 'stove'],
|
|
@@ -190,7 +210,8 @@ export function inferStations(manifest, opts = {}) {
|
|
|
190
210
|
st.recipeType = type;
|
|
191
211
|
}
|
|
192
212
|
if (kind === 'sidecar-recipe') {
|
|
193
|
-
|
|
213
|
+
// **不在这里写 command**:这时还没有侧车,写了就是让机器人去调一条不存在的命令
|
|
214
|
+
// (AGENTS 约束 2)。命令由 sidecar 编译成功后写回;没写回时 verify 报“已知但未验”。
|
|
194
215
|
st.recipeType = type;
|
|
195
216
|
}
|
|
196
217
|
stations.push(st);
|
package/src/validate.mjs
CHANGED
|
@@ -1,223 +1,231 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Manifest 校验器 —— 和宿主侧 `mod-manifest.ts` 同一套规则,去掉 TS 类型。
|
|
3
|
-
*
|
|
4
|
-
* 为什么随包发这份:任何 mineflayer 项目只要读 `<pack>.json` + `<pack>-ids.json`
|
|
5
|
-
* 两个文件就能用,那么"这两个文件长什么样"的规范必须跟包一起走,不能只存在于
|
|
6
|
-
* 某个宿主项目的源码里。
|
|
7
|
-
*
|
|
8
|
-
* 分两级:
|
|
9
|
-
* error —— 这份清单用不了(缺必填、类型错)
|
|
10
|
-
* warn —— 能用,但效果会打折(没站点、没提示词)
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const STATION_KINDS = ['two-step-use', 'place-and-wait', 'sidecar-recipe'];
|
|
14
|
-
|
|
15
|
-
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @returns {Array<{level: 'error'|'warn', path: string, message: string}>}
|
|
19
|
-
*/
|
|
20
|
-
export function validateManifest(raw) {
|
|
21
|
-
const issues = [];
|
|
22
|
-
const err = (path, message) => issues.push({ level: 'error', path, message });
|
|
23
|
-
const warn = (path, message) => issues.push({ level: 'warn', path, message });
|
|
24
|
-
|
|
25
|
-
if (!isObject(raw)) {
|
|
26
|
-
err('', 'manifest 不是一个对象');
|
|
27
|
-
return issues;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
for (const key of ['mod', 'version']) {
|
|
31
|
-
if (typeof raw[key] !== 'string' || !raw[key].trim()) err(key, `缺必填字段 ${key}(字符串)`);
|
|
32
|
-
}
|
|
33
|
-
for (const key of ['blocks', 'items', 'crops', 'names']) {
|
|
34
|
-
if (!isObject(raw[key])) err(key, `缺必填字段 ${key}(对象)`);
|
|
35
|
-
}
|
|
36
|
-
if (!Array.isArray(raw.recipes)) err('recipes', '缺必填字段 recipes(数组)');
|
|
37
|
-
|
|
38
|
-
if (raw.namespace !== undefined) {
|
|
39
|
-
if (typeof raw.namespace !== 'string' || !/^[a-z0-9_]+$/.test(raw.namespace)) {
|
|
40
|
-
err('namespace', 'namespace 只能是小写字母、数字、下划线');
|
|
41
|
-
}
|
|
42
|
-
} else {
|
|
43
|
-
warn('namespace', '没写 namespace,读取方会从方块名前缀推;生成时写死更稳');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (isObject(raw.blocks)) {
|
|
47
|
-
for (const [name, spec] of Object.entries(raw.blocks)) {
|
|
48
|
-
if (!name.includes(':')) err(`blocks.${name}`, '方块名要带命名空间(ns:name)');
|
|
49
|
-
if (!isObject(spec)) { err(`blocks.${name}`, '方块规格不是对象'); continue; }
|
|
50
|
-
if (!Array.isArray(spec.states)) err(`blocks.${name}.states`, 'states 要是数组');
|
|
51
|
-
if (typeof spec.solid !== 'boolean') err(`blocks.${name}.solid`, 'solid 要是布尔值');
|
|
52
|
-
if (spec.solid && spec.box != null && (!Array.isArray(spec.box) || spec.box.length !== 6)) {
|
|
53
|
-
err(`blocks.${name}.box`, 'box 要是 6 个数字或 null');
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (isObject(raw.items)) {
|
|
59
|
-
for (const name of Object.keys(raw.items)) {
|
|
60
|
-
if (!name.includes(':')) err(`items.${name}`, '物品名要带命名空间(ns:name)');
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (isObject(raw.crops)) {
|
|
65
|
-
for (const [name, spec] of Object.entries(raw.crops)) {
|
|
66
|
-
const at = `crops.${name}`;
|
|
67
|
-
if (!isObject(spec)) { err(at, '作物规格不是对象'); continue; }
|
|
68
|
-
if (typeof spec.seed !== 'string' || !spec.seed) err(`${at}.seed`, 'seed 必填(字符串)');
|
|
69
|
-
if (typeof spec.max_age !== 'number' || spec.max_age < 0) err(`${at}.max_age`, 'max_age 要是非负数字');
|
|
70
|
-
if (typeof spec.age_prop !== 'string' || !spec.age_prop) err(`${at}.age_prop`, 'age_prop 必填(字符串)');
|
|
71
|
-
if (!Array.isArray(spec.soils) || spec.soils.length === 0) err(`${at}.soils`, 'soils 要是非空数组');
|
|
72
|
-
if (spec.kind !== undefined && !['age-property', 'stage-blocks'].includes(spec.kind)) {
|
|
73
|
-
err(`${at}.kind`, 'kind 只认 age-property / stage-blocks');
|
|
74
|
-
}
|
|
75
|
-
if (spec.kind === 'stage-blocks' && !isObject(spec.stages)) {
|
|
76
|
-
err(`${at}.stages`, 'stage-blocks 型作物要给 stages(级号 → 方块名)');
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (raw.stations !== undefined) {
|
|
82
|
-
if (!Array.isArray(raw.stations)) {
|
|
83
|
-
err('stations', 'stations 要是数组');
|
|
84
|
-
} else {
|
|
85
|
-
const seen = new Set();
|
|
86
|
-
raw.stations.forEach((s, i) => {
|
|
87
|
-
const at = `stations[${i}]`;
|
|
88
|
-
if (!isObject(s)) { err(at, '站点定义不是对象'); return; }
|
|
89
|
-
if (typeof s.id !== 'string' || !/^[a-z0-9_]+$/.test(s.id)) {
|
|
90
|
-
err(`${at}.id`, 'id 只能是小写字母、数字、下划线');
|
|
91
|
-
} else if (seen.has(s.id)) {
|
|
92
|
-
warn(`${at}.id`, `id「${s.id}」重复;按 id 点站点时会拿到第一条`);
|
|
93
|
-
} else {
|
|
94
|
-
seen.add(s.id);
|
|
95
|
-
}
|
|
96
|
-
if (!STATION_KINDS.includes(s.kind)) err(`${at}.kind`, `kind 只认 ${STATION_KINDS.join(' / ')}`);
|
|
97
|
-
if (typeof s.block !== 'string' || !s.block.includes(':')) {
|
|
98
|
-
err(`${at}.block`, 'block 必填且要带命名空间');
|
|
99
|
-
}
|
|
100
|
-
if (s.kind === 'two-step-use' && !s.toolSuffix) {
|
|
101
|
-
warn(`${at}.toolSuffix`, 'two-step-use 没写 toolSuffix,工具只能靠猜');
|
|
102
|
-
}
|
|
103
|
-
if (s.kind === 'sidecar-recipe') {
|
|
104
|
-
if (!s.command) err(`${at}.command`, 'sidecar-recipe 必填 command(如 /xxx cook {recipe})');
|
|
105
|
-
else if (!String(s.command).includes('{recipe}')) err(`${at}.command`, 'command 里要有 {recipe}');
|
|
106
|
-
if (!s.recipeType) warn(`${at}.recipeType`, 'sidecar-recipe 没写 recipeType,配方类型只能靠猜');
|
|
107
|
-
}
|
|
108
|
-
if (s.heatTag !== undefined && (typeof s.heatTag !== 'string' || !s.heatTag.includes(':'))) {
|
|
109
|
-
err(`${at}.heatTag`, 'heatTag 要是带命名空间的 tag 名(ns:tag)');
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
warn('stations', '没有 stations:这个 mod 的方块只能靠 use 硬点');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (raw.containers !== undefined) {
|
|
118
|
-
if (!Array.isArray(raw.containers)) {
|
|
119
|
-
err('containers', 'containers 要是数组');
|
|
120
|
-
} else {
|
|
121
|
-
for (const name of raw.containers) {
|
|
122
|
-
if (typeof name !== 'string' || !name.includes(':')) {
|
|
123
|
-
err('containers', `容器名要带命名空间:${String(name)}`);
|
|
124
|
-
} else if (isObject(raw.blocks) && !(name in raw.blocks)) {
|
|
125
|
-
warn('containers', `声明了容器 ${name},但 blocks 里没有它`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (raw.candidates !== undefined) {
|
|
132
|
-
if (!isObject(raw.candidates)) {
|
|
133
|
-
err('candidates', 'candidates 要是对象(tag → 物品名数组)');
|
|
134
|
-
} else {
|
|
135
|
-
for (const [tag, list] of Object.entries(raw.candidates)) {
|
|
136
|
-
if (!Array.isArray(list) || list.some((x) => typeof x !== 'string')) {
|
|
137
|
-
err(`candidates.${tag}`, '候选要是字符串数组');
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (raw.prompt !== undefined) {
|
|
144
|
-
if (!isObject(raw.prompt)) {
|
|
145
|
-
err('prompt', 'prompt 要是对象({zh, en})');
|
|
146
|
-
} else {
|
|
147
|
-
for (const key of ['zh', 'en']) {
|
|
148
|
-
if (raw.prompt[key] !== undefined && typeof raw.prompt[key] !== 'string') {
|
|
149
|
-
err(`prompt.${key}`, 'prompt 的每种语言要是字符串');
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (raw.prompt.zh === undefined && raw.prompt.en === undefined) {
|
|
153
|
-
warn('prompt', 'prompt 里一种语言都没有');
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
} else {
|
|
157
|
-
warn('prompt', '没有 prompt:提示词里只会写「装了 X」');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (raw.unsupported !== undefined && (!Array.isArray(raw.unsupported)
|
|
161
|
-
|| raw.unsupported.some((x) => typeof x !== 'string'))) {
|
|
162
|
-
err('unsupported', 'unsupported 要是字符串数组');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ---- 配方条目:两种形状(jar 的 pattern/key、服务端的 grid/候选)都放行,但结构要对 ----
|
|
166
|
-
// schema 里 file 已降为可选、required 是 id+type;这里做同一套的运行时版本,
|
|
167
|
-
// 因为生成端读的是 validate.mjs 而不是 schema 文件。
|
|
168
|
-
if (Array.isArray(raw.recipes)) {
|
|
169
|
-
raw.recipes.forEach((r, i) => {
|
|
170
|
-
const at = `recipes[${i}]`;
|
|
171
|
-
if (!isObject(r)) { err(at, '配方条目不是对象'); return; }
|
|
172
|
-
if (typeof r.id !== 'string' || !r.id) err(`${at}.id`, '配方缺 id');
|
|
173
|
-
if (typeof r.type !== 'string' || !r.type) err(`${at}.type`, '配方缺 type');
|
|
174
|
-
const t = String(r.type ?? '');
|
|
175
|
-
if (t === 'crafting_shaped' && !Array.isArray(r.pattern) && !Array.isArray(r.grid)) {
|
|
176
|
-
err(at, 'crafting_shaped 配方要有 pattern(jar 形状)或 grid(服务端形状)');
|
|
177
|
-
}
|
|
178
|
-
if (t === 'crafting_shapeless' && !Array.isArray(r.ingredients)) {
|
|
179
|
-
err(`${at}.ingredients`, 'crafting_shapeless 配方要有 ingredients 数组');
|
|
180
|
-
}
|
|
181
|
-
// result
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Manifest 校验器 —— 和宿主侧 `mod-manifest.ts` 同一套规则,去掉 TS 类型。
|
|
3
|
+
*
|
|
4
|
+
* 为什么随包发这份:任何 mineflayer 项目只要读 `<pack>.json` + `<pack>-ids.json`
|
|
5
|
+
* 两个文件就能用,那么"这两个文件长什么样"的规范必须跟包一起走,不能只存在于
|
|
6
|
+
* 某个宿主项目的源码里。
|
|
7
|
+
*
|
|
8
|
+
* 分两级:
|
|
9
|
+
* error —— 这份清单用不了(缺必填、类型错)
|
|
10
|
+
* warn —— 能用,但效果会打折(没站点、没提示词)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const STATION_KINDS = ['two-step-use', 'place-and-wait', 'sidecar-recipe'];
|
|
14
|
+
|
|
15
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @returns {Array<{level: 'error'|'warn', path: string, message: string}>}
|
|
19
|
+
*/
|
|
20
|
+
export function validateManifest(raw) {
|
|
21
|
+
const issues = [];
|
|
22
|
+
const err = (path, message) => issues.push({ level: 'error', path, message });
|
|
23
|
+
const warn = (path, message) => issues.push({ level: 'warn', path, message });
|
|
24
|
+
|
|
25
|
+
if (!isObject(raw)) {
|
|
26
|
+
err('', 'manifest 不是一个对象');
|
|
27
|
+
return issues;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const key of ['mod', 'version']) {
|
|
31
|
+
if (typeof raw[key] !== 'string' || !raw[key].trim()) err(key, `缺必填字段 ${key}(字符串)`);
|
|
32
|
+
}
|
|
33
|
+
for (const key of ['blocks', 'items', 'crops', 'names']) {
|
|
34
|
+
if (!isObject(raw[key])) err(key, `缺必填字段 ${key}(对象)`);
|
|
35
|
+
}
|
|
36
|
+
if (!Array.isArray(raw.recipes)) err('recipes', '缺必填字段 recipes(数组)');
|
|
37
|
+
|
|
38
|
+
if (raw.namespace !== undefined) {
|
|
39
|
+
if (typeof raw.namespace !== 'string' || !/^[a-z0-9_]+$/.test(raw.namespace)) {
|
|
40
|
+
err('namespace', 'namespace 只能是小写字母、数字、下划线');
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
warn('namespace', '没写 namespace,读取方会从方块名前缀推;生成时写死更稳');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isObject(raw.blocks)) {
|
|
47
|
+
for (const [name, spec] of Object.entries(raw.blocks)) {
|
|
48
|
+
if (!name.includes(':')) err(`blocks.${name}`, '方块名要带命名空间(ns:name)');
|
|
49
|
+
if (!isObject(spec)) { err(`blocks.${name}`, '方块规格不是对象'); continue; }
|
|
50
|
+
if (!Array.isArray(spec.states)) err(`blocks.${name}.states`, 'states 要是数组');
|
|
51
|
+
if (typeof spec.solid !== 'boolean') err(`blocks.${name}.solid`, 'solid 要是布尔值');
|
|
52
|
+
if (spec.solid && spec.box != null && (!Array.isArray(spec.box) || spec.box.length !== 6)) {
|
|
53
|
+
err(`blocks.${name}.box`, 'box 要是 6 个数字或 null');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isObject(raw.items)) {
|
|
59
|
+
for (const name of Object.keys(raw.items)) {
|
|
60
|
+
if (!name.includes(':')) err(`items.${name}`, '物品名要带命名空间(ns:name)');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (isObject(raw.crops)) {
|
|
65
|
+
for (const [name, spec] of Object.entries(raw.crops)) {
|
|
66
|
+
const at = `crops.${name}`;
|
|
67
|
+
if (!isObject(spec)) { err(at, '作物规格不是对象'); continue; }
|
|
68
|
+
if (typeof spec.seed !== 'string' || !spec.seed) err(`${at}.seed`, 'seed 必填(字符串)');
|
|
69
|
+
if (typeof spec.max_age !== 'number' || spec.max_age < 0) err(`${at}.max_age`, 'max_age 要是非负数字');
|
|
70
|
+
if (typeof spec.age_prop !== 'string' || !spec.age_prop) err(`${at}.age_prop`, 'age_prop 必填(字符串)');
|
|
71
|
+
if (!Array.isArray(spec.soils) || spec.soils.length === 0) err(`${at}.soils`, 'soils 要是非空数组');
|
|
72
|
+
if (spec.kind !== undefined && !['age-property', 'stage-blocks'].includes(spec.kind)) {
|
|
73
|
+
err(`${at}.kind`, 'kind 只认 age-property / stage-blocks');
|
|
74
|
+
}
|
|
75
|
+
if (spec.kind === 'stage-blocks' && !isObject(spec.stages)) {
|
|
76
|
+
err(`${at}.stages`, 'stage-blocks 型作物要给 stages(级号 → 方块名)');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (raw.stations !== undefined) {
|
|
82
|
+
if (!Array.isArray(raw.stations)) {
|
|
83
|
+
err('stations', 'stations 要是数组');
|
|
84
|
+
} else {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
raw.stations.forEach((s, i) => {
|
|
87
|
+
const at = `stations[${i}]`;
|
|
88
|
+
if (!isObject(s)) { err(at, '站点定义不是对象'); return; }
|
|
89
|
+
if (typeof s.id !== 'string' || !/^[a-z0-9_]+$/.test(s.id)) {
|
|
90
|
+
err(`${at}.id`, 'id 只能是小写字母、数字、下划线');
|
|
91
|
+
} else if (seen.has(s.id)) {
|
|
92
|
+
warn(`${at}.id`, `id「${s.id}」重复;按 id 点站点时会拿到第一条`);
|
|
93
|
+
} else {
|
|
94
|
+
seen.add(s.id);
|
|
95
|
+
}
|
|
96
|
+
if (!STATION_KINDS.includes(s.kind)) err(`${at}.kind`, `kind 只认 ${STATION_KINDS.join(' / ')}`);
|
|
97
|
+
if (typeof s.block !== 'string' || !s.block.includes(':')) {
|
|
98
|
+
err(`${at}.block`, 'block 必填且要带命名空间');
|
|
99
|
+
}
|
|
100
|
+
if (s.kind === 'two-step-use' && !s.toolSuffix) {
|
|
101
|
+
warn(`${at}.toolSuffix`, 'two-step-use 没写 toolSuffix,工具只能靠猜');
|
|
102
|
+
}
|
|
103
|
+
if (s.kind === 'sidecar-recipe') {
|
|
104
|
+
if (!s.command) err(`${at}.command`, 'sidecar-recipe 必填 command(如 /xxx cook {recipe})');
|
|
105
|
+
else if (!String(s.command).includes('{recipe}')) err(`${at}.command`, 'command 里要有 {recipe}');
|
|
106
|
+
if (!s.recipeType) warn(`${at}.recipeType`, 'sidecar-recipe 没写 recipeType,配方类型只能靠猜');
|
|
107
|
+
}
|
|
108
|
+
if (s.heatTag !== undefined && (typeof s.heatTag !== 'string' || !s.heatTag.includes(':'))) {
|
|
109
|
+
err(`${at}.heatTag`, 'heatTag 要是带命名空间的 tag 名(ns:tag)');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
warn('stations', '没有 stations:这个 mod 的方块只能靠 use 硬点');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (raw.containers !== undefined) {
|
|
118
|
+
if (!Array.isArray(raw.containers)) {
|
|
119
|
+
err('containers', 'containers 要是数组');
|
|
120
|
+
} else {
|
|
121
|
+
for (const name of raw.containers) {
|
|
122
|
+
if (typeof name !== 'string' || !name.includes(':')) {
|
|
123
|
+
err('containers', `容器名要带命名空间:${String(name)}`);
|
|
124
|
+
} else if (isObject(raw.blocks) && !(name in raw.blocks)) {
|
|
125
|
+
warn('containers', `声明了容器 ${name},但 blocks 里没有它`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (raw.candidates !== undefined) {
|
|
132
|
+
if (!isObject(raw.candidates)) {
|
|
133
|
+
err('candidates', 'candidates 要是对象(tag → 物品名数组)');
|
|
134
|
+
} else {
|
|
135
|
+
for (const [tag, list] of Object.entries(raw.candidates)) {
|
|
136
|
+
if (!Array.isArray(list) || list.some((x) => typeof x !== 'string')) {
|
|
137
|
+
err(`candidates.${tag}`, '候选要是字符串数组');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (raw.prompt !== undefined) {
|
|
144
|
+
if (!isObject(raw.prompt)) {
|
|
145
|
+
err('prompt', 'prompt 要是对象({zh, en})');
|
|
146
|
+
} else {
|
|
147
|
+
for (const key of ['zh', 'en']) {
|
|
148
|
+
if (raw.prompt[key] !== undefined && typeof raw.prompt[key] !== 'string') {
|
|
149
|
+
err(`prompt.${key}`, 'prompt 的每种语言要是字符串');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (raw.prompt.zh === undefined && raw.prompt.en === undefined) {
|
|
153
|
+
warn('prompt', 'prompt 里一种语言都没有');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
warn('prompt', '没有 prompt:提示词里只会写「装了 X」');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (raw.unsupported !== undefined && (!Array.isArray(raw.unsupported)
|
|
161
|
+
|| raw.unsupported.some((x) => typeof x !== 'string'))) {
|
|
162
|
+
err('unsupported', 'unsupported 要是字符串数组');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- 配方条目:两种形状(jar 的 pattern/key、服务端的 grid/候选)都放行,但结构要对 ----
|
|
166
|
+
// schema 里 file 已降为可选、required 是 id+type;这里做同一套的运行时版本,
|
|
167
|
+
// 因为生成端读的是 validate.mjs 而不是 schema 文件。
|
|
168
|
+
if (Array.isArray(raw.recipes)) {
|
|
169
|
+
raw.recipes.forEach((r, i) => {
|
|
170
|
+
const at = `recipes[${i}]`;
|
|
171
|
+
if (!isObject(r)) { err(at, '配方条目不是对象'); return; }
|
|
172
|
+
if (typeof r.id !== 'string' || !r.id) err(`${at}.id`, '配方缺 id');
|
|
173
|
+
if (typeof r.type !== 'string' || !r.type) err(`${at}.type`, '配方缺 type');
|
|
174
|
+
const t = String(r.type ?? '');
|
|
175
|
+
if (t === 'crafting_shaped' && !Array.isArray(r.pattern) && !Array.isArray(r.grid)) {
|
|
176
|
+
err(at, 'crafting_shaped 配方要有 pattern(jar 形状)或 grid(服务端形状)');
|
|
177
|
+
}
|
|
178
|
+
if (t === 'crafting_shapeless' && !Array.isArray(r.ingredients)) {
|
|
179
|
+
err(`${at}.ingredients`, 'crafting_shapeless 配方要有 ingredients 数组');
|
|
180
|
+
}
|
|
181
|
+
// 原版有明确产物形状的类型才强制 result.id;自定义序列化器(immersiveengineering:crusher
|
|
182
|
+
// 之类的 basePredicate、twilightforest 的自定义 result)可以有别的形状。
|
|
183
|
+
const RESULT_REQUIRED = new Set([
|
|
184
|
+
'crafting_shaped', 'crafting_shapeless',
|
|
185
|
+
'smelting', 'blasting', 'smoking', 'campfire_cooking',
|
|
186
|
+
'stonecutting', 'smithing_transform',
|
|
187
|
+
]);
|
|
188
|
+
if (r.result !== undefined && r.result !== null && !Array.isArray(r.result)) {
|
|
189
|
+
if (RESULT_REQUIRED.has(t)) {
|
|
190
|
+
if (!isObject(r.result) || typeof r.result.id !== 'string') {
|
|
191
|
+
err(`${at}.result`, 'result 要带 id');
|
|
192
|
+
} else if (r.result.count !== undefined
|
|
193
|
+
&& (!Number.isInteger(r.result.count) || r.result.count < 1)) {
|
|
194
|
+
err(`${at}.result.count`, 'result.count 要是正整数');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return issues;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const hasErrors = (issues) => issues.some((i) => i.level === 'error');
|
|
205
|
+
|
|
206
|
+
export const describeIssues = (issues) =>
|
|
207
|
+
issues.map((i) => `${i.level === 'error' ? '✗' : '!'} ${i.path || '(根)'}: ${i.message}`);
|
|
208
|
+
|
|
209
|
+
/** 校验通过后当成 manifest 用;错了给 null。 */
|
|
210
|
+
export function parseManifest(raw) {
|
|
211
|
+
return hasErrors(validateManifest(raw)) ? null : raw;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 统计口径:声明了多少、实际可用多少。 */
|
|
215
|
+
export function statsOf(manifest, ids = null) {
|
|
216
|
+
const declaredItems = Object.keys(manifest?.items ?? {}).length;
|
|
217
|
+
const usableItems = ids?.items ? Object.keys(ids.items).length : declaredItems;
|
|
218
|
+
const failed = Array.isArray(ids?.failed) ? ids.failed : [];
|
|
219
|
+
return {
|
|
220
|
+
blocks: Object.keys(manifest?.blocks ?? {}).length,
|
|
221
|
+
states: Object.values(manifest?.blocks ?? {}).reduce((n, b) => n + (b.states?.length ?? 0), 0),
|
|
222
|
+
recipes: (manifest?.recipes ?? []).length,
|
|
223
|
+
crops: Object.keys(manifest?.crops ?? {}).length,
|
|
224
|
+
stations: (manifest?.stations ?? []).length,
|
|
225
|
+
containers: (manifest?.containers ?? []).length,
|
|
226
|
+
declaredItems,
|
|
227
|
+
usableItems,
|
|
228
|
+
blockOnlyItems: failed.filter((f) => f.kind === 'block-only').length,
|
|
229
|
+
unknownItems: failed.filter((f) => f.kind === 'unknown-item').length,
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/verify.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { checkServerMods } from './server-fingerprint.mjs';
|
|
|
17
17
|
import { makeRcon } from './rcon.mjs';
|
|
18
18
|
import { fallbackTag } from './tag-fallback.mjs';
|
|
19
19
|
import { vanillaItemTable } from './recipes-join.mjs';
|
|
20
|
+
import { stationSkipReason } from './stations.mjs';
|
|
20
21
|
|
|
21
22
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
23
|
|
|
@@ -64,6 +65,8 @@ export default async function cmdVerify(args, io) {
|
|
|
64
65
|
|
|
65
66
|
const results = [];
|
|
66
67
|
const add = (kind, ok, detail) => results.push({ kind, ok, detail });
|
|
68
|
+
// 已知但未验:不是通过,也不是失败;回执里显式列出来。
|
|
69
|
+
const skip = (kind, detail) => results.push({ kind, ok: null, skipped: true, detail });
|
|
67
70
|
|
|
68
71
|
const cmd = await makeRcon(host, rconPort, rconPassword);
|
|
69
72
|
const bot = mineflayer.createBot({ host, port, username, version: mcVersion, auth: 'offline' });
|
|
@@ -175,17 +178,17 @@ export default async function cmdVerify(args, io) {
|
|
|
175
178
|
else add('station', false, `包里有过 ${blocks} 种方块但没有站点声明(先跑 learn --infer)`);
|
|
176
179
|
}
|
|
177
180
|
// **不许静默跳过**:以前这个 filter 把 place-and-wait 直接丢掉,报告里看不到任何
|
|
178
|
-
// 痕迹——那正是"静默失败"
|
|
179
|
-
//
|
|
180
|
-
const EXECUTABLE = new Set(['sidecar-recipe', 'two-step-use', 'place-and-wait']);
|
|
181
|
+
// 痕迹——那正是"静默失败"。现在缺 recipeType 的按失败报,机制没执行器、侧车命令
|
|
182
|
+
// 没写回的按"已知但未验"显式列出(不伪装成通过,也不冤枉数据)。
|
|
181
183
|
for (const st of all) {
|
|
182
184
|
if (!st?.recipeType) {
|
|
183
185
|
add('station', false, `${st?.id ?? '(无 id)'}:站点缺 recipeType,没法挑配方执行`);
|
|
184
|
-
|
|
185
|
-
add('station', false, `${st.id}:机制 ${st.kind} 还没有执行器(已知但未验)`);
|
|
186
|
+
continue;
|
|
186
187
|
}
|
|
188
|
+
const why = stationSkipReason(st);
|
|
189
|
+
if (why) skip('station', `${st.id}:${why}`);
|
|
187
190
|
}
|
|
188
|
-
const stations = all.filter((st) =>
|
|
191
|
+
const stations = all.filter((st) => st.recipeType && !stationSkipReason(st));
|
|
189
192
|
|
|
190
193
|
// 两步式站点:需要把物品放上去、再手持工具右键。用真实交互而不是命令,
|
|
191
194
|
// 这样验的是"机器人能不能做得到",不是"服务端能不能代做"。
|
|
@@ -444,18 +447,23 @@ export default async function cmdVerify(args, io) {
|
|
|
444
447
|
await cmd(`forceload remove ${bx - 8} ${bz - 8} ${bx + 8} ${bz + 8}`);
|
|
445
448
|
await sleep(300);
|
|
446
449
|
bot.quit();
|
|
450
|
+
cmd.close();
|
|
447
451
|
|
|
448
|
-
const failed = results.filter((r) =>
|
|
452
|
+
const failed = results.filter((r) => r.ok === false);
|
|
453
|
+
const unverified = results.filter((r) => r.skipped === true);
|
|
449
454
|
console.error(`\n=== verify ${manifest.mod} ===`);
|
|
450
|
-
for (const r of results)
|
|
451
|
-
|
|
455
|
+
for (const r of results) {
|
|
456
|
+
console.error(` ${r.skipped ? '·' : r.ok ? '✓' : '✗'} [${r.kind}] ${r.detail}`);
|
|
457
|
+
}
|
|
458
|
+
console.error(`\n${failed.length === 0 ? '全部通过' : `${failed.length} 项没过`}`
|
|
459
|
+
+ (unverified.length > 0 ? `;${unverified.length} 项已知但未验` : ''));
|
|
452
460
|
|
|
453
461
|
if (typeof args.out === 'string') {
|
|
454
462
|
const { writeIds } = await import('./manifest-io.mjs');
|
|
455
463
|
writeIds(args.out, { mod: manifest.mod, verifiedAt: new Date().toISOString(), results });
|
|
456
464
|
}
|
|
457
465
|
if (failed.length > 0) process.exitCode = 1;
|
|
458
|
-
console.log(JSON.stringify({ mod: manifest.mod, failed: failed.length, results }, null, 2));
|
|
466
|
+
console.log(JSON.stringify({ mod: manifest.mod, failed: failed.length, unverified: unverified.length, results }, null, 2));
|
|
459
467
|
}
|
|
460
468
|
|
|
461
469
|
/**
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# reference-bot
|
|
2
|
+
|
|
3
|
+
最小的参考消费者:**不依赖 Cortico**,只证明一份 `manifest + ids` 能被普通
|
|
4
|
+
mineflayer 机器人消费。
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm i modsmith mineflayer
|
|
8
|
+
node index.mjs \
|
|
9
|
+
--host 127.0.0.1 --port 25565 \
|
|
10
|
+
--manifest ../../mods/<pack>.json \
|
|
11
|
+
--ids ../../mods/<pack>-ids.json \
|
|
12
|
+
--username ModReference
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
它做三件事:
|
|
16
|
+
|
|
17
|
+
1. 在 `inject_allowed` 之前把 manifest + ids 注入 `bot.registry`;
|
|
18
|
+
2. 丢掉只读验证不需要的大包(`declare_recipes` / `declare_commands` / `entity_metadata`);
|
|
19
|
+
3. 打印方块 state 数、实体显示名与 boss 标记,然后退出。
|
|
20
|
+
|
|
21
|
+
它不 setblock、不召唤、不聊天,所以可以安全地指向生产服做一次只读验收。
|
|
22
|
+
需要写世界抽样的完整 live 验收见仓库里的 `modsmith conformance --live`。
|