From b0182de8c7e27610af6f89ea29b27102f483b602 Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 25 Sep 2026 14:33:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcfold):=20P6=20=E4=B8=96=E7=95=8C?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=88=E5=A4=9A=E5=8C=BA=E5=9D=97=E8=81=9A?= =?UTF-8?q?=E5=90=88/=E8=B4=9F=E5=9D=90=E6=A0=87=E5=AE=9A=E4=BD=8D/unloade?= =?UTF-8?q?d=E4=B8=8Eair=E8=AF=AD=E4=B9=89=E5=88=86=E6=98=8E/mod=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E7=BB=9F=E8=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- mcfold/__pycache__/world.cpython-312.pyc | Bin 0 -> 6757 bytes mcfold/world.py | 119 ++++++++++++++++++++++ tests/test_world.py | 120 +++++++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 mcfold/__pycache__/world.cpython-312.pyc create mode 100644 mcfold/world.py create mode 100644 tests/test_world.py diff --git a/README.md b/README.md index a58aed7..fa14526 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ 可提取:mod id / 名称 / 版本 / **方块名清单** / 物品名清单 **诚实边界**:拿不到 state id(已在摘要里明说,见附录 D) +- [x] **P6** 世界模型(`mcfold/world.py`,7 项回归全绿) + 多区块聚合 / 负坐标定位 / 世界高度边界 + **关键语义**:`unloaded`(没收到)与 `air`(真是空气)**必须可区分** + mod 方块计入 `unknown_state_ids`,世界摘要带告警 + ## 累计测试 | 套件 | 项数 | @@ -63,5 +68,6 @@ | protodef | 6 | | 区块解析 | 11 | | mod jar | 8 | +| 世界模型 | 7 | | 跨实现互操作 | 2 | -| **合计** | **33** | +| **合计** | **40** | diff --git a/mcfold/__pycache__/world.cpython-312.pyc b/mcfold/__pycache__/world.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..370920b5ec9af1532a1f1b214615bd82377bfb24 GIT binary patch literal 6757 zcmb7JYj70TmF}MDo@vcPqX&-#^JvM6teJpVVr<72U<`5$UZcc-V?3C2hUpdznuq9~ zG0a3%iI~R-3nUWR8u2jF5{w8$(r&yg$UKs&{Ge+0#|&~-N*7hj+M3bm7xF5pROQFk zo^yM8G=OkbTFmX+_kGX(&Uel|{D<9cq2TG7du{KgGK%^dKlF#yGW2v8WV$Jla#JEL z8vJy?;5N{hH~NhM#?1s+H%q=5zbRmLn**GigKt(e`HKP;w-sS@b%0sbrJvjRw&!S`V}CvZ&2j9Fd^o z`1MaFK5ozS_oYAm=;6)N*+Zw(o$+-0aQa;L!<+4y_BO3S`dnwG_gLoq#mpx+pz7<^ zpIa>H!5it}E z#1TF_a63EDm;LO}#LsWQq~q65777C-kv{TU+yqP$zXG#s8|Diefef?Pj>8G?O4t*Y zc%R6R-#qPdO`hwS{33x%>rD?G&cr)m1Q<};sX z!@-fWcX}qSeg;U$d~z()cVV*UaQfEAd_50KnE2wC*@0n%%E0Bxo)Z(pmvF30hYF>Y z>F!Nm7@F+r$qo+Ukduf^C#_mN!C(mX=?euFr$H?eJzQ=` zt>K-`!q(R}Y}@q1hOMeil)NEP5(uokIRzmvqxL~huZK)GB~fk~l+^GJsHWQ}8YD(E zu7(xO)+3gXkk9b zZhhLE92v7jEP^>rdheMx^tPNclHS}*sh&GPE6A+Yt(4Q&pmMqzN2;JfUgwb<1~2Bw z;Z7p+uYH-WYoHS#;=kEKk?`_Rqefbh3h0)-b|iglSfjwnD+zKG@hG3Zb7Zpbi}cxk zK4z$`m2oW*#1jr;&ne+@aw;)chNhE|*PsC~RcjagL)Fck9nSo=KYg@4+t&{YlljfB z@*IGgt10NYi@;z7I;zsII$3!>3?*AJv0+kPssgCAay=W;6sstfvrN*p| zj=HwGgzKKQYOMTa{j>I-wPtMYi=Rh^c39VVOQ&H*ssX6MBun9l z9F*t5H*f+c2&e;spjre0SS;elyiE}HMLhmojad-HkQa&_f>6Lcf*|uS$Yn~#LzZ!4 za*3Xxa1|^%Lj4c({+MOX2s3A_xb(x#W44kH-x{+yKK!RYGiH|8`eL`)c_j=rGrXd+ zio`Y=izxk1w24MA$0jh#4Dg6qEEZXKb5fD$5KV`fyh*o+C1C6sw^b|^Ie6Q|GO-BW zcCnl=P_Z}%3_Ig?L=DclD*G;)-N+JHfrj+SGe?2U`S0eKUgINR<_2$GN1-k1G2w+b z$r_ZZbV*=$qG#V5fTL_KvP9d55eFI`oe+`D<*rd)xVcv zTPGx~)Rc`{P!VU^?PIH6gqL+}QB9orl3Oq~cOiUQ9;|#m&is*ELeBLOxgZk}xon3_ zHx-_d0u6G2z3(@qWLg+#k(Y;FPf;LWL!$xBNDH%-l5wR@qg)IxZJV`N zz>>|Y2JZp*#Z(4OCgk=gNaDEvoB$k1`kI*&uuW{PCWpt0Ux8i<3bB>CZ(oq8Ozi5d z>Sso4UQX4#JYs((ZW!Y$<4lLO&DyyoQP#OJ#VsCll*WrRq2!Mk2EYvwp{#?<(~QX8 z!XD5LA8p$rrMspy70qqlAmf%*V{@n(1VIjc6%@!XK%(GC`Q6$ya@Y17E7P`GSoSJ* z`3FdVt2i6(r^C_aNR%bEB)HzHKXT7$8_J&~w8RO>hVURBIpx{UH`iA`IdEP4QjqLG zUJN5BSf4u_n`V&(ZzvKB>&q=HTaO)(!CR?uM_K2p?z&TTBaWrOe&2#d7t8>He~Nu> z0!%9No>n_!rO`Gwy$@0@4(IMHV5{M#A{s?T$1qr2T4ZBIaCk=TeUJrQ&uXLhLaWCg z&;Yj4a3v?PgQgZ!>yxvY3T89yMeFw118XqHU<}kbEq5gcuY+b-i~X_Y@Y!P4=LDaC zi)@CX!s|@IwE?(LyHudhb$vMd`FU^@HgDi_g+u=Wt}-~q<2T|HUH#z5X&zaFjEesQ zc_E~es1Wi3%sC)&nh8y52AbFw4coh=uxi!0P4IyCCdiT!@_!&XIZf5+11cR=>6o0y zzWfpl2=1IuRM6D`Eg(;h`F5dC3?u2xr+>czO_eT4TB!$CTRhwmYl|IUn|Qmo>XNI^ zb+si`S$A)7-Tm@s67{_mz3=oljFi_UZFLVy%Q`)$<|RrKo}PK}^}q=B(vBT%J353m z0obB4v3`*uIwzy7jmqXZU(az_pzMNk4z3t%&ApS^ilgDi4S3#|j=prOUk z`pVw{Ww^NO%q23YJhS(0p^@0XZM8FX0BGC^JF^p~sbQP^3=BX3MjT9H|G*p&Gl+mv z&BVzGHOecoAySCWnj5hI>EsdClR6g`EG>j@j?bVGMdji9fAI0N#R60c zAx}C2NRT*wx)-S&2FHRo%W#;3d%!z^g^%wd%jjCNYm>3FB2)nAb{pjcldnDiPJz=%{QQwhR z+ea?GD15jq(bMQ;;US4M61J6sMH?D4HLHOF2vr!GJ}9){+-kokuuJr;jV;w+qC^4^ z;)>R;2KM#%m9>!38Z<$F1>=IP)bHfG6~B-CzL7v1#xw!|8zzyC)H#nXRq+Ix{Zix< zRAnx-X8!FXxRE}7MTDE`*)}je`CB6Y)iH>Akr>eU?T@qzBLu0&hmYcg5rl>YYxs;7 zf`F(JgtqRRThQUD@6nn5$dwOT>Z>%OWvCp=x{a-SY(q8x(+ zTy+z*q;hm|ZEA7t*xUu(?o;mji)y|xGfORT|?pZ7DJD2ru8(qF3wS2>f zvp&8dS@NBft@^&J_I}B{&hY5`<*E70N9RAEn*aRB{1;MnZ+%&^`R=Z}`;sMZPaEm) zZl$qDHL%l^i7tJQo-$JQlFp*GhAECJDUVyeg^|7wt9+W0&*t;$%2epz6$8 z6QooQK7kY#3PGa87HG(urAkK0%^{KlZ+DbilH!((a#bk~Jl$#yeI{Z!Vspr?UXP&V zf=SwS10vYDv&9@7Ht|15DLUXX$srZ1?5=27Qshc3MNV>>W^If0DudA~Kmuu;8~AnX zNQV1CK8N0lRz_N)frE=5F=V9WOsO_jY=Isl+!2m|?(Bd_*fJiHojY^0#flM=J0J+Q zjEA72UMzcY=o0x^oUjU$?_g4m3DTq%SNR_1@Ra2`NK~#_4mC@1II3yDCakE(qydwk zV1nqCkvxdJwU~knKQVE_PuT;>Vd_sz4dBaF3%5_Wcuo~r*;Nn9s;5|Z1M255oHAn$ z5jc%?(~FF3*|ddbYoOLt{J#d8<)@91n<6>R%2rMU z8tXpdVyGNk;t40qyP?eo@KY8+g3d)r=WG9EtxmALrk>)IwK~aEYblXe1K@223^=|9 zhQ@r3001%WEGLJBQJ=;L1cV^!BjdhQYd)L|;AF5Pp;}Wo+6?R^o3O-;4OtR^AkdYO zsS84B1OaFrw0ICXK-|e=C#!O+wTRPN*$=ho2q>E&nKIHe{f)s!Gk;k`(a%m$HUCZV z6V&!mYC9aC!TMK2G0jg=kiZ!ltW)^;*UC3(`nhi?OsCui>e-s1mcLN&K?L*v;^tYJ literal 0 HcmV?d00001 diff --git a/mcfold/world.py b/mcfold/world.py new file mode 100644 index 0000000..2aaf2b9 --- /dev/null +++ b/mcfold/world.py @@ -0,0 +1,119 @@ +"""世界模型:聚合区块,按世界坐标查方块。 + +对应 mineflayer 侧的 prismarine-world(811 行)。 +职责: + * 以 (chunkX, chunkZ) 为键存区块列 + * 世界坐标 (x, y, z) → 方块名(跨区块自动定位) + * 未加载区域**显式**返回 unloaded,而不是假装是空气 + +★ 设计要点(与 mineflayer 的关键区别): + mineflayer 对越界 state id 会**静默**映射成别的方块; + 本实现返回 `unknown()`,并把该 id 归因到「可能是 mod 方块」。 + 这符合文档附录 D 的结论:让降级可见,而不是无声错误。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +from .chunk import Chunk, BlockRegistry, MIN_Y, WORLD_HEIGHT, NUM_SECTIONS, decode_chunk + + +@dataclass +class BlockHit: + """一次方块查询的结果(含来源说明,便于 agent 理解可信度)。""" + name: str | None # 方块名;None 表示未知/未加载 + state_id: int + loaded: bool # 该区块是否已加载 + source: str # "registry" | "unknown-state" | "unloaded" | "out-of-world" + + def describe(self) -> str: + if self.source == "unloaded": + return "未加载(该区块还没收到)" + if self.source == "out-of-world": + return f"超出世界高度(y 必须在 {MIN_Y}..{MIN_Y + WORLD_HEIGHT - 1})" + if self.source == "unknown-state": + return f"unknown({self.state_id}) —— 本机注册表无此 state id,可能来自 mod 方块" + return self.name or "?" + + +class World: + """区块集合 + 方块查询。""" + + def __init__( + self, + registry: BlockRegistry | None = None, + num_sections: int = NUM_SECTIONS, + min_y: int = MIN_Y, + ): + self.registry = registry + self.chunks: dict[tuple[int, int], Chunk] = {} + self.num_sections = num_sections + self.min_y = min_y + # 统计:用于给 agent 报告「我看到了什么」 + self.unknown_state_ids: dict[int, int] = {} # state_id -> 出现次数 + + # ---------- 装载 ---------- + def load_chunk_data(self, cx: int, cz: int, data: bytes) -> Chunk: + ch = decode_chunk(cx, cz, data, self.num_sections) + self.chunks[(cx, cz)] = ch + return ch + + def unload_chunk(self, cx: int, cz: int) -> None: + self.chunks.pop((cx, cz), None) + + @property + def loaded_count(self) -> int: + return len(self.chunks) + + # ---------- 查询 ---------- + def block_at(self, x: int, y: int, z: int) -> BlockHit: + """世界坐标 → 方块。坐标可为负(MC 坐标就是这么用的)。""" + if y < self.min_y or y >= self.min_y + WORLD_HEIGHT: + return BlockHit(None, 0, False, "out-of-world") + + cx, cz = x >> 4, z >> 4 # Python 的 >> 对负数即 floor 除 16 ✓ + ch = self.chunks.get((cx, cz)) + if ch is None: + return BlockHit(None, 0, False, "unloaded") + + sid = ch.state_id_at(x & 15, y, z & 15) + return self._resolve(sid) + + def _resolve(self, sid: int) -> BlockHit: + if self.registry is None: + return BlockHit(None, sid, True, "unknown-state") + name = self.registry.name_of(sid) + if name is None: + self.unknown_state_ids[sid] = self.unknown_state_ids.get(sid, 0) + 1 + return BlockHit(None, sid, True, "unknown-state") + return BlockHit(name, sid, True, "registry") + + def columns(self) -> Iterable[tuple[int, int]]: + return self.chunks.keys() + + # ---------- 摘要(给 agent 用) ---------- + def summary(self) -> dict[str, Any]: + """世界现状摘要。**含降级提示**,让 agent 知道数据可信度。""" + out: dict[str, Any] = { + "loaded_chunks": self.loaded_count, + "min_y": self.min_y, + "world_height": WORLD_HEIGHT, + } + if self.unknown_state_ids: + out["unknown_state_count"] = len(self.unknown_state_ids) + out["unknown_state_sample"] = dict( + sorted(self.unknown_state_ids.items(), key=lambda kv: -kv[1])[:5]) + out["warning"] = ( + "检测到本机注册表没有的 state id ⇒ 本实例可能装了 mod。" + "这些方块会显示为 unknown(id),**不是**某个原版方块。" + ) + return out + + def reset_unknown_stats(self) -> None: + self.unknown_state_ids.clear() + + +def in_world(y: int, min_y: int = MIN_Y, height: int = WORLD_HEIGHT) -> bool: + return min_y <= y < min_y + height diff --git a/tests/test_world.py b/tests/test_world.py new file mode 100644 index 0000000..2121f8d --- /dev/null +++ b/tests/test_world.py @@ -0,0 +1,120 @@ +"""世界模型回归:区块聚合 + 世界坐标查询 + 显式降级。""" +import sys, os, json, subprocess +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mcfold.world import World, in_world +from mcfold.chunk import BlockRegistry, MIN_Y, WORLD_HEIGHT +from mcfold.protodef import Writer + +RUNTIME = "/home/newqqagent/plugin_data/mc/runtime" +BLOCKS = f"{RUNTIME}/node_modules/minecraft-data/minecraft-data/data/pc/1.20/blocks.json" + +def reg(): return BlockRegistry.from_json_file(BLOCKS) + +def test_negative_coords(): + """★ 负坐标定位:MC 世界坐标可为负,这是最容易错的地方。""" + w = World(reg()) + # 在 (-1,-1) 区块放一个方块:(x=-1,z=-1) ⇒ chunk (-1,-1), 内部 (15,15) + assert (-1) >> 4 == -1 and (-1) & 15 == 15 + # 先确认未加载时报 unloaded(而不是假装空气) + hit = w.block_at(-1, 64, -1) + assert not hit.loaded and hit.source == "unloaded", hit + print(" ✓ 负坐标:未加载区块显式返回 unloaded(非假装空气)") + +def test_unloaded_vs_air_distinction(): + """★ 关键语义:「未加载」与「空气」必须可区分。 + + mineflayer 的 bot.blockAt 在未加载时返回 null,但很多调用方当空气处理; + 我们要求显式区分,避免 agent 误判。 + """ + w = World(reg()) + unloaded = w.block_at(0, 64, 0) + assert unloaded.source == "unloaded", unloaded + # 加载一个全空气区块后,同一坐标应变成 air(registry) + wr = Writer() + for _ in range(24): + wr.write((0).to_bytes(2,"big")); wr.write(bytes([0])) + wr.varint(0); wr.varint(0) + wr.write(bytes([0])); wr.varint(0); wr.varint(0) + w.load_chunk_data(0, 0, wr.getvalue()) + air = w.block_at(0, 64, 0) + assert air.loaded and air.name == "air", (air.loaded, air.name) + print(" ✓ 未加载(unloaded) 与 空气(air) 语义分明") + +def test_real_chunk_via_world(): + """用 prismarine 产出的真实区块走完 World 全路径。""" + r = subprocess.run(["node", f"{RUNTIME}/chunkgen.js"], capture_output=True, + text=True, cwd=RUNTIME, timeout=120) + if r.returncode != 0: + print(f" - 跳过({r.stderr[:80]})"); return + d = json.loads(r.stdout) + w = World(reg()) + w.load_chunk_data(0, 0, bytes.fromhex(d["hex"])) + checks = [((0,-60,0),"stone"), ((1,-59,2),"gold_block"), + ((5,-59,7),"diamond_block"), ((3,20,4),"diamond_block")] + for (x,y,z), want in checks: + hit = w.block_at(x,y,z) + assert hit.loaded and hit.name == want, (x,y,z,hit) + print(f" ✓ World 全路径: 4/4 方块命中(解真实区块数据)") + +def test_world_height_bounds(): + w = World(reg()) + assert w.block_at(0, MIN_Y-1, 0).source == "out-of-world" + assert w.block_at(0, MIN_Y+WORLD_HEIGHT, 0).source == "out-of-world" + assert w.block_at(0, MIN_Y, 0).source == "unloaded" # 合法高度但未加载 + assert in_world(MIN_Y) and not in_world(MIN_Y-1) + print(f" ✓ 世界高度边界: [{MIN_Y}, {MIN_Y+WORLD_HEIGHT}) 之外显式 out-of-world") + +def test_unknown_state_reported(): + """★ mod 方块的可见降级:unknown(id) 且计入统计。""" + r = reg() + w = World(r) + wr = Writer() + # section0: palette=[99999] + wr.write((1).to_bytes(2,"big")); wr.write(bytes([4])) + wr.varint(1); wr.varint(99999); wr.varint(256) + wr.write(b"\x00"*(256*8)) + wr.write(bytes([0])); wr.varint(0); wr.varint(0) + for _ in range(23): + wr.write((0).to_bytes(2,"big")); wr.write(bytes([0])) + wr.varint(0); wr.varint(0) + wr.write(bytes([0])); wr.varint(0); wr.varint(0) + w.load_chunk_data(0, 0, wr.getvalue()) + hit = w.block_at(0, MIN_Y, 0) # section 0 的首个方块 + assert hit.source == "unknown-state", hit + assert "mod" in hit.describe(), hit.describe() + s = w.summary() + assert s.get("unknown_state_count") == 1, s + assert "mod" in s.get("warning",""), s + print(f" ✓ ★ mod 方块可见降级: {hit.describe()[:40]}… + 世界摘要含告警") + +def test_multiple_chunks(): + """多区块聚合:跨区块查询各自命中自己的列。""" + w = World(reg()) + wr = Writer() + for _ in range(24): + wr.write((0).to_bytes(2,"big")); wr.write(bytes([0])) + wr.varint(0); wr.varint(0) + wr.write(bytes([0])); wr.varint(0); wr.varint(0) + data = wr.getvalue() + for cx, cz in [(0,0), (1,0), (-1,-1), (2,-3)]: + w.load_chunk_data(cx, cz, data) + assert w.loaded_count == 4, w.loaded_count + # (16,64,0) 属于区块 (1,0) + assert w.block_at(16,64,0).loaded + # (-1,64,-1) 属于区块 (-1,-1) + assert w.block_at(-1,64,-1).loaded + # (32,64,-48) 属于区块 (2,-3) + assert w.block_at(32,64,-48).loaded + # (16,64,-1) 属于区块 (1,-1) —— 未加载 + assert w.block_at(16,64,-1).source == "unloaded" + print(" ✓ 多区块聚合: 4 列,跨区块/负坐标定位均正确,未加载区可辨") + # 卸载 + w.unload_chunk(1, 0) + assert w.block_at(16,64,0).source == "unloaded" + print(" ✓ 区块卸载后查询回到 unloaded") + +if __name__ == "__main__": + for fn in [test_negative_coords, test_unloaded_vs_air_distinction, test_real_chunk_via_world, + test_world_height_bounds, test_unknown_state_reported, test_multiple_chunks]: + fn() + print("\n世界模型:全部通过")