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/crops.mjs
CHANGED
|
@@ -1,227 +1,246 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 作物推断。
|
|
3
|
-
*
|
|
4
|
-
* 这是整条管线里唯一"必须猜"的地方:jar 里没有"这个方块是作物、成熟时 age 是 3"
|
|
5
|
-
* 这种数据。FD 的番茄 blockstate 有 age 0..7,实际只长到 3 —— 猜错会让校准脚本拿
|
|
6
|
-
* 无效状态去 setblock,堆一屏失败。
|
|
7
|
-
*
|
|
8
|
-
* 所以走三档,从可信到不可信:
|
|
9
|
-
* 1. sidecar 覆盖(人手写的 `crops.sidecar.json`)—— 最可信
|
|
10
|
-
* 2. blockstate 的 age 值域 —— 常见情况够用
|
|
11
|
-
* 3. 名字启发式 + 种子物品存在性 —— 只在没有 age 属性时才用
|
|
12
|
-
*
|
|
13
|
-
* **推断出来的每条都带 `_inferred` 标记**,`modsmith learn` 会按需修正,人也能直接改。
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** 这些 tag 基本只出现在作物上。 */
|
|
17
|
-
const CROP_TAGS = ['crops', 'maintains_farmland', 'sword_efficient'];
|
|
18
|
-
/** 名字像作物的后缀。 */
|
|
19
|
-
/** 名字像植物的后缀。**带上复数形式**:tomatoes / cabbages / onions 都是复数。 */
|
|
20
|
-
const CROP_NAME_HINTS = /(^|_)(crops?|crops?es|colony|bush|plant|vine|sprouts?|sapling|berries|tomatoes|potatoes|cabbages|onions|carrots|beetroots|wheats?)$/;
|
|
21
|
-
/** 明显不是作物的(作物农场里出现的"土"和"箱子")。 */
|
|
22
|
-
const NOT_CROP = /(farmland|soil|rich_soil|basket|cabinet|crate|_block$|_sack$)/;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* @param {{
|
|
26
|
-
* blocks: Record<string, {states: Array<Record<string,string>>, tags: string[], solid: boolean, drops: string[]}>,
|
|
27
|
-
* blockstates: Map<string, unknown>,
|
|
28
|
-
* namespace: string,
|
|
29
|
-
* recipes: Array<Record<string, unknown>>,
|
|
30
|
-
* sidecar?: Record<string, unknown>,
|
|
31
|
-
* drop?: string[],
|
|
32
|
-
* }} input
|
|
33
|
-
*/
|
|
34
|
-
export function inferCrops({ blocks, items = {}, namespace, recipes, sidecar = {}, drop = [] }) {
|
|
35
|
-
const crops = {};
|
|
36
|
-
const notes = [];
|
|
37
|
-
const dropped = new Set(drop);
|
|
38
|
-
|
|
39
|
-
// 手写覆盖优先:它是唯一可信的来源。
|
|
40
|
-
// 下划线开头的是注释/元数据(readSidecar 会剥,这里再兜一层,防止直接传裸 JSON)
|
|
41
|
-
for (const [key, spec] of Object.entries(sidecar)) {
|
|
42
|
-
if (key.startsWith('_')) continue;
|
|
43
|
-
crops[key] = { ...spec, _source: 'sidecar' };
|
|
44
|
-
notes.push(`${key}: 来自 sidecar`);
|
|
45
|
-
}
|
|
46
|
-
for (const key of Object.keys(crops)) {
|
|
47
|
-
if (dropped.has(key)) {
|
|
48
|
-
delete crops[key];
|
|
49
|
-
notes.push(`${key}: sidecar 要求丢弃`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// sidecar 已经挂成别名的,别再当独立作物推断
|
|
54
|
-
const declaredAliases = new Set(Object.values(crops).flatMap((c) => c.aliases ?? []));
|
|
55
|
-
|
|
56
|
-
// ---- 先把"长得像作物"的全收进来,再统一决定谁是正主、谁是别名 ----
|
|
57
|
-
// 顺序很关键:`budding_tomatoes` 和 `tomatoes` 都是作物形态,
|
|
58
|
-
// "先到先得"的写法会把前者当独立作物(踩过)。
|
|
59
|
-
const candidates = [];
|
|
60
|
-
for (const [full, spec] of Object.entries(blocks)) {
|
|
61
|
-
const short = full.slice(namespace.length + 1);
|
|
62
|
-
if (crops[short] || dropped.has(short) || declaredAliases.has(short)) continue;
|
|
63
|
-
if (NOT_CROP.test(short)) continue;
|
|
64
|
-
const ages = agesOf(spec.states);
|
|
65
|
-
if (ages === null) continue;
|
|
66
|
-
const looksLike = CROP_NAME_HINTS.test(short)
|
|
67
|
-
|| (spec.tags ?? []).some((t) => CROP_TAGS.includes(t));
|
|
68
|
-
if (!looksLike) continue;
|
|
69
|
-
candidates.push({ short, ages });
|
|
70
|
-
}
|
|
71
|
-
// 名字短的优先当正主:'tomatoes' 比 'budding_tomatoes' 短
|
|
72
|
-
candidates.sort((a, b) => a.short.length - b.short.length);
|
|
73
|
-
|
|
74
|
-
for (const { short, ages } of candidates) {
|
|
75
|
-
if (crops[short]) continue;
|
|
76
|
-
const aliasOwner = Object.keys(crops).find((c) => {
|
|
77
|
-
const base = stripPlural(c);
|
|
78
|
-
return base.length >= 3 && short.includes(base) && c !== short;
|
|
79
|
-
});
|
|
80
|
-
if (aliasOwner) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 作物推断。
|
|
3
|
+
*
|
|
4
|
+
* 这是整条管线里唯一"必须猜"的地方:jar 里没有"这个方块是作物、成熟时 age 是 3"
|
|
5
|
+
* 这种数据。FD 的番茄 blockstate 有 age 0..7,实际只长到 3 —— 猜错会让校准脚本拿
|
|
6
|
+
* 无效状态去 setblock,堆一屏失败。
|
|
7
|
+
*
|
|
8
|
+
* 所以走三档,从可信到不可信:
|
|
9
|
+
* 1. sidecar 覆盖(人手写的 `crops.sidecar.json`)—— 最可信
|
|
10
|
+
* 2. blockstate 的 age 值域 —— 常见情况够用
|
|
11
|
+
* 3. 名字启发式 + 种子物品存在性 —— 只在没有 age 属性时才用
|
|
12
|
+
*
|
|
13
|
+
* **推断出来的每条都带 `_inferred` 标记**,`modsmith learn` 会按需修正,人也能直接改。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 这些 tag 基本只出现在作物上。 */
|
|
17
|
+
const CROP_TAGS = ['crops', 'maintains_farmland', 'sword_efficient'];
|
|
18
|
+
/** 名字像作物的后缀。 */
|
|
19
|
+
/** 名字像植物的后缀。**带上复数形式**:tomatoes / cabbages / onions 都是复数。 */
|
|
20
|
+
const CROP_NAME_HINTS = /(^|_)(crops?|crops?es|colony|bush|plant|vine|sprouts?|sapling|berries|tomatoes|potatoes|cabbages|onions|carrots|beetroots|wheats?)$/;
|
|
21
|
+
/** 明显不是作物的(作物农场里出现的"土"和"箱子")。 */
|
|
22
|
+
const NOT_CROP = /(farmland|soil|rich_soil|basket|cabinet|crate|_block$|_sack$)/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{
|
|
26
|
+
* blocks: Record<string, {states: Array<Record<string,string>>, tags: string[], solid: boolean, drops: string[]}>,
|
|
27
|
+
* blockstates: Map<string, unknown>,
|
|
28
|
+
* namespace: string,
|
|
29
|
+
* recipes: Array<Record<string, unknown>>,
|
|
30
|
+
* sidecar?: Record<string, unknown>,
|
|
31
|
+
* drop?: string[],
|
|
32
|
+
* }} input
|
|
33
|
+
*/
|
|
34
|
+
export function inferCrops({ blocks, items = {}, namespace, recipes, sidecar = {}, drop = [] }) {
|
|
35
|
+
const crops = {};
|
|
36
|
+
const notes = [];
|
|
37
|
+
const dropped = new Set(drop);
|
|
38
|
+
|
|
39
|
+
// 手写覆盖优先:它是唯一可信的来源。
|
|
40
|
+
// 下划线开头的是注释/元数据(readSidecar 会剥,这里再兜一层,防止直接传裸 JSON)
|
|
41
|
+
for (const [key, spec] of Object.entries(sidecar)) {
|
|
42
|
+
if (key.startsWith('_')) continue;
|
|
43
|
+
crops[key] = { ...spec, _source: 'sidecar' };
|
|
44
|
+
notes.push(`${key}: 来自 sidecar`);
|
|
45
|
+
}
|
|
46
|
+
for (const key of Object.keys(crops)) {
|
|
47
|
+
if (dropped.has(key)) {
|
|
48
|
+
delete crops[key];
|
|
49
|
+
notes.push(`${key}: sidecar 要求丢弃`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// sidecar 已经挂成别名的,别再当独立作物推断
|
|
54
|
+
const declaredAliases = new Set(Object.values(crops).flatMap((c) => c.aliases ?? []));
|
|
55
|
+
|
|
56
|
+
// ---- 先把"长得像作物"的全收进来,再统一决定谁是正主、谁是别名 ----
|
|
57
|
+
// 顺序很关键:`budding_tomatoes` 和 `tomatoes` 都是作物形态,
|
|
58
|
+
// "先到先得"的写法会把前者当独立作物(踩过)。
|
|
59
|
+
const candidates = [];
|
|
60
|
+
for (const [full, spec] of Object.entries(blocks)) {
|
|
61
|
+
const short = full.slice(namespace.length + 1);
|
|
62
|
+
if (crops[short] || dropped.has(short) || declaredAliases.has(short)) continue;
|
|
63
|
+
if (NOT_CROP.test(short)) continue;
|
|
64
|
+
const ages = agesOf(spec.states);
|
|
65
|
+
if (ages === null) continue;
|
|
66
|
+
const looksLike = CROP_NAME_HINTS.test(short)
|
|
67
|
+
|| (spec.tags ?? []).some((t) => CROP_TAGS.includes(t));
|
|
68
|
+
if (!looksLike) continue;
|
|
69
|
+
candidates.push({ short, ages });
|
|
70
|
+
}
|
|
71
|
+
// 名字短的优先当正主:'tomatoes' 比 'budding_tomatoes' 短
|
|
72
|
+
candidates.sort((a, b) => a.short.length - b.short.length);
|
|
73
|
+
|
|
74
|
+
for (const { short, ages } of candidates) {
|
|
75
|
+
if (crops[short]) continue;
|
|
76
|
+
const aliasOwner = Object.keys(crops).find((c) => {
|
|
77
|
+
const base = stripPlural(c);
|
|
78
|
+
return base.length >= 3 && short.includes(base) && c !== short;
|
|
79
|
+
});
|
|
80
|
+
if (aliasOwner) {
|
|
81
|
+
const spec = blocks[`${namespace}:${short}`];
|
|
82
|
+
const drops = spec?.drops ?? [];
|
|
83
|
+
const owner = crops[aliasOwner];
|
|
84
|
+
const seedOnly = drops.length > 0 && drops.every((d) => d === owner.seed || /(^|_)seeds?$/.test(d));
|
|
85
|
+
if (seedOnly && ages.max !== owner.max_age) {
|
|
86
|
+
// budding_tomatoes 这类长苗块只掉种子,不是成熟收获形态;并进 tomatoes
|
|
87
|
+
// 会让 collect tomatoes 把苗当熟果拔掉。留在地里,不独立成作物也不当 alias。
|
|
88
|
+
notes.push(`${short}: 长苗阶段(只掉种子,age ${ages.max} vs ${owner.max_age}),不当别名`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const list = owner.aliases ?? [];
|
|
92
|
+
if (!list.includes(short)) {
|
|
93
|
+
owner.aliases = [...list, short];
|
|
94
|
+
notes.push(`${short}: 当 ${aliasOwner} 的别名(推断)`);
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const seed = guessSeed(short, blocks, recipes, namespace);
|
|
99
|
+
crops[short] = {
|
|
100
|
+
seed,
|
|
101
|
+
max_age: ages.max,
|
|
102
|
+
age_prop: 'age',
|
|
103
|
+
soils: guessSoils(blocks, namespace),
|
|
104
|
+
_inferred: true,
|
|
105
|
+
_source: 'blockstate',
|
|
106
|
+
};
|
|
107
|
+
notes.push(`${short}: age 0..${ages.max},种子 ${seed}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- 另一种作物写法:每个生长阶段是一个独立方块(`x_stage_0`..`x_stage_7`) ----
|
|
111
|
+
// 老 mod 常见(1.13 之前的主流写法)。它没有 age 属性,整块换名,所以和上面的推断
|
|
112
|
+
// 完全平行。识别出来后在 crops 里标 `kind: 'stage-blocks'`,宿主按另一条路处理。
|
|
113
|
+
const stageGroups = new Map();
|
|
114
|
+
for (const full of Object.keys(blocks)) {
|
|
115
|
+
const short = full.slice(namespace.length + 1);
|
|
116
|
+
const m = /^(.+?)_stage_(\d+)$/.exec(short);
|
|
117
|
+
if (!m) continue;
|
|
118
|
+
const base = m[1];
|
|
119
|
+
const n = Number(m[2]);
|
|
120
|
+
const g = stageGroups.get(base) ?? { max: -1, stages: {} };
|
|
121
|
+
g.max = Math.max(g.max, n);
|
|
122
|
+
g.stages[n] = short;
|
|
123
|
+
stageGroups.set(base, g);
|
|
124
|
+
}
|
|
125
|
+
for (const [base, g] of stageGroups) {
|
|
126
|
+
if (crops[base]) continue;
|
|
127
|
+
if (g.max < 1) continue; // 只有 stage_0 说明不是分阶段作物
|
|
128
|
+
const canonical = base.replace(/_(plant|bush|crop)$/, '');
|
|
129
|
+
// 分阶段方块这类老写法常常没有单独的种子物品:种下去的就是 stage_0 那一块本身
|
|
130
|
+
const stage0 = g.stages[0] ?? `${base}_stage_0`;
|
|
131
|
+
const seed = pickExistingSeed(
|
|
132
|
+
[`${namespace}:${canonical}_seeds`, `${namespace}:${base}_seeds`, `${namespace}:${stage0}`],
|
|
133
|
+
items,
|
|
134
|
+
);
|
|
135
|
+
crops[base] = {
|
|
136
|
+
seed,
|
|
137
|
+
max_age: g.max,
|
|
138
|
+
age_prop: 'stage',
|
|
139
|
+
kind: 'stage-blocks',
|
|
140
|
+
stages: g.stages,
|
|
141
|
+
soils: guessSoils(blocks, namespace),
|
|
142
|
+
_inferred: true,
|
|
143
|
+
_source: 'stage-blocks',
|
|
144
|
+
};
|
|
145
|
+
notes.push(`${base}: 分阶段方块 0..${g.max},种子 ${seed}`);
|
|
146
|
+
for (const st of Object.values(g.stages)) {
|
|
147
|
+
if (crops[st]) continue;
|
|
148
|
+
// 每一阶段方块也算这个作物的形态,但**不独立成作物**
|
|
149
|
+
crops[base].aliases = [...(crops[base].aliases ?? []), st];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- 第二遍:把上一轮没赶上(因为正主还没出现)的别名归位 ----
|
|
154
|
+
for (const [full, spec] of Object.entries(blocks)) {
|
|
155
|
+
const short = full.slice(namespace.length + 1);
|
|
156
|
+
if (crops[short] || dropped.has(short)) continue;
|
|
157
|
+
if (NOT_CROP.test(short)) continue;
|
|
158
|
+
const ages = agesOf(spec.states);
|
|
159
|
+
if (ages === null) continue;
|
|
160
|
+
const owner = Object.keys(crops).find((c) => {
|
|
161
|
+
const base = stripPlural(c);
|
|
162
|
+
return base.length >= 3 && short.includes(base) && c !== short;
|
|
163
|
+
});
|
|
164
|
+
if (!owner) continue;
|
|
165
|
+
const blockSpec = blocks[`${namespace}:${short}`];
|
|
166
|
+
const drops = blockSpec?.drops ?? [];
|
|
167
|
+
const crop = crops[owner];
|
|
168
|
+
const seedOnly = drops.length > 0 && drops.every((d) => d === crop.seed || /(^|_)seeds?$/.test(d));
|
|
169
|
+
if (seedOnly && ages.max !== crop.max_age) {
|
|
170
|
+
notes.push(`${short}: 长苗阶段(只掉种子,age ${ages.max} vs ${crop.max_age}),不当别名`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const list = crop.aliases ?? [];
|
|
174
|
+
if (!list.includes(short)) {
|
|
175
|
+
crop.aliases = [...list, short];
|
|
176
|
+
notes.push(`${short}: 当 ${owner} 的别名`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { crops, cropNotes: notes };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 从状态表里取 age 的最小/最大值;没有 age 属性给 null。 */
|
|
184
|
+
function agesOf(states) {
|
|
185
|
+
const values = states
|
|
186
|
+
.map((s) => s.age)
|
|
187
|
+
.filter((v) => v !== undefined)
|
|
188
|
+
.map((v) => Number.parseInt(v, 10))
|
|
189
|
+
.filter((n) => Number.isFinite(n));
|
|
190
|
+
if (values.length === 0) return null;
|
|
191
|
+
return { min: Math.min(...values), max: Math.max(...values) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 从候选里挑第一个真实存在的物品名;都不存在就给第一个(留给 learn 阶段纠正)。 */
|
|
195
|
+
function pickExistingSeed(candidates, items) {
|
|
196
|
+
for (const c of candidates) {
|
|
197
|
+
if (c in items) return c;
|
|
198
|
+
}
|
|
199
|
+
return candidates[0];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 去复数:tomatoes → tomato、cabbages → cabbage、onions → onion。 */
|
|
203
|
+
function stripPlural(name) {
|
|
204
|
+
if (name.endsWith('ies')) return `${name.slice(0, -3)}y`;
|
|
205
|
+
if (name.endsWith('es') && /(sh|ch|x|s)es$/.test(name)) return name.slice(0, -2);
|
|
206
|
+
if (name.endsWith('s')) return name.slice(0, -1);
|
|
207
|
+
return name;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 猜种子物品名。三档:
|
|
212
|
+
* 1. 配方里"这个方块 ← 某种子"的种植配方(部分 mod 有)
|
|
213
|
+
* 2. `<作物名>_seeds`
|
|
214
|
+
* 3. 作物名的单数形式本身(FD 的洋葱/水稻就是直接种 onion / rice)
|
|
215
|
+
*/
|
|
216
|
+
function guessSeed(short, blocks, recipes, namespace) {
|
|
217
|
+
const single = stripPlural(short);
|
|
218
|
+
const candidates = [`${namespace}:${short}_seeds`, `${namespace}:${single}_seeds`,
|
|
219
|
+
`${namespace}:${single}`, `${namespace}:${short}`];
|
|
220
|
+
// 优先级 1:种植配方里 result 是这个方块、原料里有种子
|
|
221
|
+
for (const c of candidates) {
|
|
222
|
+
if (hasPlantingRecipe(recipes, namespace, short, c)) return c;
|
|
223
|
+
}
|
|
224
|
+
return pickExistingSeed(candidates, blocks);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function hasPlantingRecipe(recipes, namespace, cropBlock, seed) {
|
|
228
|
+
const full = `${namespace}:${cropBlock}`;
|
|
229
|
+
for (const r of recipes) {
|
|
230
|
+
const res = r.result;
|
|
231
|
+
const resId = Array.isArray(res) ? res[0]?.id : res?.id;
|
|
232
|
+
if (resId !== full) continue;
|
|
233
|
+
const ings = Array.isArray(r.ingredients) ? r.ingredients : [];
|
|
234
|
+
for (const ing of ings) {
|
|
235
|
+
const id = Array.isArray(ing) ? ing[0]?.item : ing?.item;
|
|
236
|
+
if (id === seed) return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 能种在哪些方块上:优先找名字像土的,否则给原版农田。 */
|
|
243
|
+
function guessSoils(blocks, namespace) {
|
|
244
|
+
const soils = Object.keys(blocks).filter((b) => /(farmland|rich_soil$|_soil$)/.test(b));
|
|
245
|
+
return soils.length > 0 ? soils : ['minecraft:farmland'];
|
|
246
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry dump → modsmith ids.
|
|
3
|
+
*
|
|
4
|
+
* `templates/registry-dump` 在服务端启动时把真实注册表写成 JSON:
|
|
5
|
+
* blocks: { "ns:id": [{ props, stateId }] }
|
|
6
|
+
* items: { "ns:id": numericId }
|
|
7
|
+
*
|
|
8
|
+
* 这里只做**过滤与校验**:只留 manifest 声明的方块/物品,剔除落在 vanilla 区间里的
|
|
9
|
+
* 可疑 stateId、跨方块重复的 stateId、以及 dump 里不存在的条目。不调 LLM、不连服务器。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { vanillaBlockStateCeiling } from './recipes-join.mjs';
|
|
13
|
+
import { fingerprintDir } from './server-fingerprint.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 1.21.1 原版实体 id 上限(0..129)。模组实体必然注册在原版之后,落在原版区间里的
|
|
17
|
+
* 只可能是残留/坏 dump——收下就是给实体错名(错名比未知更糟)。
|
|
18
|
+
*/
|
|
19
|
+
const VANILLA_ENTITY_CEILING = 129;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} dump
|
|
23
|
+
* @param {object} manifest
|
|
24
|
+
* @param {{ceiling?: number}} [opts]
|
|
25
|
+
*/
|
|
26
|
+
export function idsFromDump(dump, manifest, opts = {}) {
|
|
27
|
+
if (!dump || typeof dump !== 'object' || !dump.blocks || !dump.items) {
|
|
28
|
+
throw new Error('dump 缺少 blocks/items;这不是 modsmith-dump 产出的文件?');
|
|
29
|
+
}
|
|
30
|
+
const ceiling = Number(opts.ceiling ?? vanillaBlockStateCeiling('1.21.1')) || 0;
|
|
31
|
+
const blocks = {};
|
|
32
|
+
const items = {};
|
|
33
|
+
const failed = [];
|
|
34
|
+
const seenState = new Map();
|
|
35
|
+
|
|
36
|
+
for (const name of Object.keys(manifest.blocks ?? {})) {
|
|
37
|
+
const raw = dump.blocks[name];
|
|
38
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
39
|
+
failed.push({ block: name, resp: 'dump 里没有这个方块(服务端未注册)' });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const list = [];
|
|
43
|
+
for (const entry of raw) {
|
|
44
|
+
const stateId = Number(entry?.stateId);
|
|
45
|
+
if (!Number.isInteger(stateId) || stateId <= 0) {
|
|
46
|
+
failed.push({ block: name, props: entry?.props, resp: `stateId 非法:${entry?.stateId}` });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ceiling > 0 && stateId <= ceiling) {
|
|
50
|
+
failed.push({ block: name, props: entry?.props, resp: `stateId ${stateId} 落在 vanilla 区间(<= ${ceiling})` });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const owner = seenState.get(stateId);
|
|
54
|
+
if (owner && owner !== name) {
|
|
55
|
+
failed.push({ block: name, props: entry?.props, resp: `stateId ${stateId} 已被 ${owner} 占用` });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
seenState.set(stateId, name);
|
|
59
|
+
list.push({ props: entry.props ?? {}, stateId });
|
|
60
|
+
}
|
|
61
|
+
if (list.length > 0) blocks[name] = list;
|
|
62
|
+
else failed.push({ block: name, resp: '所有状态都被剔除' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const seenItem = new Map();
|
|
66
|
+
for (const name of Object.keys(manifest.items ?? {})) {
|
|
67
|
+
const id = Number(dump.items[name]);
|
|
68
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
69
|
+
failed.push({ item: name, kind: 'unknown-item', resp: 'dump 里没有这个物品(服务端未注册)' });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const owner = seenItem.get(id);
|
|
73
|
+
if (owner && owner !== name) {
|
|
74
|
+
failed.push({ item: name, resp: `物品 id ${id} 已被 ${owner} 占用` });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
seenItem.set(id, name);
|
|
78
|
+
items[name] = id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 只收这个包自己命名空间的实体;boss 标记来自清单的 bosses(jar 里的 entity tag)
|
|
82
|
+
const entities = {};
|
|
83
|
+
const seenEntity = new Map();
|
|
84
|
+
const ns = String(manifest.namespace ?? '');
|
|
85
|
+
const bosses = new Set(manifest.bosses ?? []);
|
|
86
|
+
for (const [name, raw] of Object.entries(dump.entities ?? {})) {
|
|
87
|
+
if (!ns || !name.startsWith(`${ns}:`)) continue;
|
|
88
|
+
// 0.1.2 起是 { id, spawnGroup, width, height };更早的 dump 只有裸数字
|
|
89
|
+
const id = Number(typeof raw === 'object' && raw !== null ? raw.id : raw);
|
|
90
|
+
if (!Number.isInteger(id) || id <= 0) continue;
|
|
91
|
+
if (id <= VANILLA_ENTITY_CEILING) {
|
|
92
|
+
failed.push({ entity: name, resp: `数字 id ${id} 落在原版实体区间(<= ${VANILLA_ENTITY_CEILING})` });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const owner = seenEntity.get(id);
|
|
96
|
+
if (owner && owner !== name) continue; // 数字 id 重复:先到先得,不猜
|
|
97
|
+
seenEntity.set(id, name);
|
|
98
|
+
const entry = { id };
|
|
99
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
100
|
+
if (typeof raw.spawnGroup === 'string') entry.spawnGroup = raw.spawnGroup;
|
|
101
|
+
if (Number.isFinite(raw.width)) entry.width = raw.width;
|
|
102
|
+
if (Number.isFinite(raw.height)) entry.height = raw.height;
|
|
103
|
+
}
|
|
104
|
+
if (bosses.has(name)) entry.boss = true;
|
|
105
|
+
entities[name] = entry;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { blocks, items, entities, failed };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} dumpPath
|
|
113
|
+
* @param {string} manifestPath
|
|
114
|
+
* @param {{idsPath?: string, serverMods?: string, mcv?: string}} [opts]
|
|
115
|
+
*/
|
|
116
|
+
export function importDump(dumpPath, manifestPath, opts = {}) {
|
|
117
|
+
const dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
|
|
118
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
119
|
+
const mcv = String(opts.mcv ?? '1.21.1');
|
|
120
|
+
const ceiling = vanillaBlockStateCeiling(mcv);
|
|
121
|
+
const { blocks, items, entities, failed } = idsFromDump(dump, manifest, { ceiling });
|
|
122
|
+
const ids = {
|
|
123
|
+
mod: manifest.mod,
|
|
124
|
+
version: manifest.version,
|
|
125
|
+
measuredAt: new Date().toISOString(),
|
|
126
|
+
method: 'registry-dump',
|
|
127
|
+
blocks,
|
|
128
|
+
items,
|
|
129
|
+
entities,
|
|
130
|
+
failed,
|
|
131
|
+
};
|
|
132
|
+
if (opts.serverMods) {
|
|
133
|
+
const fp = fingerprintDir(opts.serverMods);
|
|
134
|
+
ids.serverMods = { fingerprint: fp.fingerprint, files: fp.files };
|
|
135
|
+
}
|
|
136
|
+
const out = opts.idsPath ?? manifestPath.replace(/\.json$/, '-ids.json');
|
|
137
|
+
writeFileSync(out, `${JSON.stringify(ids, null, 1)}\n`);
|
|
138
|
+
return { out, ids, failed, hasEntities: Boolean(dump.entities && Object.keys(dump.entities).length > 0) };
|
|
139
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { craftRecipeViews } from './recipes-view.mjs';
|
|
|
17
17
|
import { vanillaItemTable } from './recipes-join.mjs';
|
|
18
18
|
import { discoverSerializers, sidesheetFromDiscovery } from './recipes-discover.mjs';
|
|
19
19
|
import { buildRecipeGraph, planTarget, outputOf, outputCountOf, canMake } from './plan.mjs';
|
|
20
|
+
import { checkManifest, checkIds, checkRegistry, runConformance, summarize } from './conformance.mjs';
|
|
20
21
|
|
|
21
22
|
export { inspectJar, readSidecar, writeSidecar, sidecarTemplateFrom, writeManifest };
|
|
22
23
|
|
|
@@ -28,6 +29,7 @@ export {
|
|
|
28
29
|
checkServerMods, fingerprintDir, craftRecipeViews, vanillaItemTable,
|
|
29
30
|
discoverSerializers, sidesheetFromDiscovery,
|
|
30
31
|
buildRecipeGraph, planTarget, outputOf, outputCountOf, canMake,
|
|
32
|
+
checkManifest, checkIds, checkRegistry, runConformance, summarize,
|
|
31
33
|
};
|
|
32
34
|
|
|
33
|
-
export const VERSION = '0.
|
|
35
|
+
export const VERSION = '0.7.1';
|