From 5038c7f67978e5570557b0a9351ab0db8756ce6a Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 25 Sep 2026 14:36:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcfold):=20P7=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E5=B1=82=EF=BC=88=E4=BC=A0=E5=85=A5=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E2=86=92=E6=8B=89=E8=B5=B7=E5=AE=9E=E4=BE=8B=E2=86=92=E8=AF=BB?= =?UTF-8?q?mod=E6=B3=A8=E5=86=8C=E6=96=B9=E5=9D=97=E8=A1=A8=E2=86=92?= =?UTF-8?q?=E8=BF=9E=E6=9C=8D=EF=BC=8C=E5=90=AB=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=EF=BC=89+=20=E7=BB=9F=E4=B8=80=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- mcfold/__pycache__/client.cpython-312.pyc | Bin 9794 -> 10143 bytes mcfold/__pycache__/instance.cpython-312.pyc | Bin 0 -> 15969 bytes mcfold/__pycache__/modjar.cpython-312.pyc | Bin 12538 -> 13071 bytes mcfold/client.py | 7 + mcfold/instance.py | 291 ++++++++++++++++++++ mcfold/modjar.py | 14 +- run_tests.sh | 23 ++ tests/test_instance.py | 175 ++++++++++++ 9 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 mcfold/__pycache__/instance.cpython-312.pyc create mode 100644 mcfold/instance.py create mode 100755 run_tests.sh create mode 100644 tests/test_instance.py diff --git a/README.md b/README.md index fa14526..1c18210 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ **关键语义**:`unloaded`(没收到)与 `air`(真是空气)**必须可区分** mod 方块计入 `unknown_state_ids`,世界摘要带告警 +- [x] **P7** 实例层(`mcfold/instance.py`,8 项回归全绿) + **传入文件夹路径 → 拉起实例 → 读 mod 注册方块表 → 连服** + 含:加载器版本归一化、裸名展开、歧义处理、越界 state id 归因到 mod + ## 累计测试 | 套件 | 项数 | @@ -69,5 +73,9 @@ | 区块解析 | 11 | | mod jar | 8 | | 世界模型 | 7 | +| mod jar | 8 | +| 实例层 | 8 | | 跨实现互操作 | 2 | -| **合计** | **40** | +| **合计** | **48** | + +统一入口:`./run_tests.sh` diff --git a/mcfold/__pycache__/client.cpython-312.pyc b/mcfold/__pycache__/client.cpython-312.pyc index e93faf93828c97bb464d2295d963957666b3dbb7..210c4b12a3f5613b609fa12712a3d84558d06c44 100644 GIT binary patch delta 1251 zcmZuxOKcNY6n%Fxlf)j|i9ead*!kFTA{+t)@+ml^YDGv=N=axCDwJXddq`rypf?j} zBZrVKswxcx=OG!SN&yK3DpCa+sa+tkU{#e((>5Zqx}Xy4%54^9$-86BM`9$OIq%#v z_ujd4+<_ zB@?zeT+4oO`aH+W`pFlAgiJ#QtO#Lhm5(jBD_vB~KtcdAN-~ftXKI62h7;=v(P6$V z10`FCAA7k$z%JgtiVure>w{5Z*h-yHSN9MuH^9`=J{1wJz`I7a{uo6D~405K+4Pry-gvbsTlL3YK{ z2cztFPZx}v72dx9hS)P-r+*)BdCIf%wFv&c?Mb)r4`*?5GI=>|>uQ8(@T; zSL$Kb{9f51`1uYH8sw0#IHMbMB0WYGls;nr*46s=BK?>{X-txCsD?&AVIluUxM&Xh ze}s^gVho*i2(OKGr>++GR@%s0;Jg8#&r_y z;Q{uzaXN^ffu2A(#UU?IH9e-$gD4m**|fzwi!#LN0hDxv9(Jc`3*0pGO)EIGd*MKH zKi75Kye8^K%N&O($IBrlPM+4ahwPv5d3hd#eESHUXXlzX&0Io?ixL&7)v#r-x<DML3lM%YT)r(ggN#g(ly|g`r)a|U67Cz%1M={ zP+`$Z;#CSZi`t2&rXX9?LF%GwPNZw=Yo*ZIR=2bZ)>>J2%g)du&ziT#On+T8==DJ#Xo)IxRr`e*}?M^Vkcw@F;`; delta 1053 zcmZvb*-sNu6vpQaGc4Wc0^L}oEQN-EASB3QjI0p|g#aNMwW;k;s#x%LFxV6!CTb7_ z^(Mv_;vZlzm`T)Eebh%a!3i#jPrfxY(S#@OnF8|QJpArA-+cGX+;i_d^S>K3eJ~nz z9J}tAUtQ^QzBILq@I~nt7X_$Q-skCn-{R;QtD7eVI$UV)80F+FjN=uC=a5lHJpRCo z@C%THZtkLs;}-ZNAI_o|^ku#{tU8i>hzD+z6F501Q-Bo{4kq3PpE#75px+G+x?z|S zNd?PL(OByhNGX#)245CA#s*ak1!Lj33LzRcRuy(LD;{82;uvYjwrvi& zXZ#5x$~V(6z_@bU+zN1#MlDe|r+l=O^3X$fZKq&_HWV~NP>~9D0D5Vsy~*Wgg%Z}T z>8KQw>jOjz-3UnY!H_(wtl2#Pe!AtTg(14*D1n5s=jh;FJ!}RkW>9lx<1&fHBE*hX zAN9K(u0fP%84f=_E(c|a4A4b)9o$tm-7mnSm5?y$MEsk>YYS%KWN`yLR5pwApt_w! zRDnprFe$=xHiIua6KqAJ4KYT)mNdar#Zvl*_e?RT_g4XkvKZ6MC@+(r)_L+vR!0s;as`U~*2LBT^th@ZTjh ch|`Ek`rCU6p3w2?_WUUxN_IIUF|%iX0q802hX4Qo diff --git a/mcfold/__pycache__/instance.cpython-312.pyc b/mcfold/__pycache__/instance.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b7dc482b48cb296ca5c07f2c8b31263be5dae200 GIT binary patch literal 15969 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 literal 0 HcmV?d00001 diff --git a/mcfold/__pycache__/modjar.cpython-312.pyc b/mcfold/__pycache__/modjar.cpython-312.pyc index 4a6cf564b2bf6b8743eed1b16f3be9d21dd322f0..f8ee30dda0872f0d2c7767f99d36ad62e5252f96 100644 GIT binary patch delta 2072 zcmZuxeQZ-z6o2=%ed|_O_tkZb4azoTg>GF3lVl9E6Ndv?B!i&S@$C9uNug`UeJ$(* zY(of+2pP*&1_QzpL|G)D7Ihj-OiYXhO|}_@y!Z!3HXtT4ogs0F(Q|G$z{HpK_s%=# z+;h+G+;i@XuP=~!eg6Cm0Uk^K$GaNKKFC~YB8ynP>9?8%itUi`Jv717lA{zV`G<@x zxynMt4(~iQTPacs9`mquPsa&wO%vAvc7`lMn49ddki4JpG-G^0L;k$bK6TOFl$EUB3o(B zi9h1C6ZkmA*alcXdGz&(%Y%2%k4&EUbmHv5#PMSjXD&{jx#Vb%D304BZ`>I?G;#Up zSP4u3+GyK{8Ye#-b9Hh%27A*&ut3XCuDB|BO^UCm2EP`5q zUC-MS^M!(epsL8+MUw*&+Fit2(+jqvxCWfGN&u*r#ddP*jpB+i_*p7$=GuB2Z|2$` zm2Bfs-DpJn9>F&fs5 zZIMU_cE;4{0syCxR-l}N@JKuBfm&z2ZkBoZWm(UVWtN?>Ueb-BpcZ8<8E$tOu3UkD zwdR=*3w&RGOf(C%2zY4PRdzY!MCr67nw}xkW}~TkYMvlv{vnE{CNgaR@pmrLrOD?l z4@`D`&=$GFM>SQBM%qKP1)?-2U&=l}$kODE94{&4KADOdRsB&p6!9r4)o2^;nyk+I zPLy6jv6VrvCS2@Hqd3V_MtBtgCRWgmyMtkcHgmCEjrz1S5FM!})o&RxReg$#M%oU> zPPW(H3rYNAKP-|ucDS&GtY$wK=1Ula#p1>0g-kla~RWRTY3!l&7h#qM}2Y5aZ;p%Fkg_!Nb1L`l*jG^#32iBr*~RR7Z` zy7GQnj?#7jO#*nFg+TSbyHWwnNi*Dyd@Fsf1| zNck2N?ZVX+AMCgZbo3bWmFGUO1rAE+^haOTqSWQ^hPzr}pm%`7 zjI}%15eSCV%9db91H2y_?vw|;fN`qWkCh$^@7T9f-#KoV1tZMa8{JRY`Ne=U4AI|2 z+dC=Wb8efRlX;haBVZI~0j*)z-GvhG-Gl5`_nK`_q7g&z2~N!?P7xmk-iZ7td968H zT8mb^j8nxh*TGrDLh3qt33PZ@YMrd7s5x(A`;bW>#1N zwhv8hqYdayOdD*A4uzZ+CgvnU^v+`KI-^20wWDk)>Rony#gf;B5?3U>niy&$6sN)bHP zqbX2E%bf((w5KF2$JKnWn-;R|c+7TuDA(21K|OyE=sGirtFzLh!W~N!7bi9f4}aG6 zb>A0#mxr%MuS9Pg-KcH9Enkp-?CD({z8~)12zTErimZpbSJ`ssa^tdonJqi-2k(Zv z*NY-|1Eb$G-g55`jjRui+#49h_1_ys5yy`w+zj>CE1K-7V7h6rJ!LADN@y~)Q0<`# zK`Z5d1Yh&R9+df^g7$C&X_^r}mY|X3dV=j+Kx*d-zftf(;EC$>V<##D~RR= zqcb`6$S&nn>w}2@ae@+8f!S@^5AJ1d1@i ztE(F7$ym#SQQ8?J7$+z}@RZY*ZNL#8ttu}U9`mDM>P9qax;mUfhRxrpx}Z|uN7S5iLB}vH!?az?%|IF{ zZ)HbngOpBeSGP}nj*NQvwcYJ!a*@+|fZzav=hF=X21!z#6u73LsUjawol2x)rya0~ zDw;+k=_rDuB4C%`Ckl@hbz-_5we_Uwz&t815$hJhS?NiER9VB*XAeH$uhf5uQOOP8 z%j|XjLwLB1stUUZs3N%})4ac_lFjq+rf#;6FE^E%qP$d8Q2@^$6{Q8*C`a&oZ<+wL zw9F8QfcKJunNAV@MWFvdF63>w9!RDPIFFpQY+%oezW5xO9wI~PFla&83>j9;op4-et?RclllWp#={Jk!t(mx$ zFx$qhnS=NjJqpvxCont z^d3zdNf|HO@f4jdl3U%<19{adVLyM^QLTy&Uf}<9Jb%1{7-IHm9>u8U`iX`?w4=By zQG1a+bdiEaFISvEFWyZ$VZH!sxJU1)Gs~Acdn(G=7<()c{^^tC;A2^c5&lzWvqI+o E0@cob>i_@% diff --git a/mcfold/client.py b/mcfold/client.py index 2fc195e..5ba78a2 100644 --- a/mcfold/client.py +++ b/mcfold/client.py @@ -37,6 +37,7 @@ class Client: self.proto: dict = {} self.proto_num: int = 0 self.compression: int = -1 + # 允许直接指定协议数据文件(优先),否则按 data_root 找 self._path = Path(protocol_path) if protocol_path else None # ---------- 协议数据 ---------- @@ -67,6 +68,12 @@ class Client: def _data_root(self) -> Path: if self._path: + # 传进来的可能是 data 根,也可能是 data/pc —— 都容忍 + for c in (self._path, self._path / "pc", self._path.parent): + if (c / "pc").is_dir(): + return c + if c.name == "pc" and c.is_dir(): + return c.parent return self._path cands = [ Path("/home/newqqagent/plugin_data/mc/runtime/node_modules/" diff --git a/mcfold/instance.py b/mcfold/instance.py new file mode 100644 index 0000000..38c4cf2 --- /dev/null +++ b/mcfold/instance.py @@ -0,0 +1,291 @@ +"""实例层:传入文件夹路径 → 拉起一个完整实例。 + +这是用户口径的落点(2026-09-25): + "支持传入文件夹路径,按照版本文件夹内容,拉起实例, + 并读取 mod jar 注册方块表。然后链接服务端,获取到数据。" + +实例文件夹 == 一个完整的 MC 客户端安装(与官方启动器一致): + + instances// ← 传进来的就是它(== .minecraft) + versions//.json ← 决定协议版本(含加载器继承) + versions//.jar ← 客户端本体(渲染器) + mods/*.jar ← mod(决定方块名清单) + config/ options.txt saves/ ← 实例私有 + +本模块把这些组合起来,并对**每一项能力标注可信度**: + * 协议版本 → 可信(来自 version.json + inheritsFrom) + * 方块名 → 原版可信;mod 方块名只有名字、没有 state id(附录 D) + * 世界数据 → 连接后才有;未加载区域显式可辨 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .chunk import BlockRegistry, MIN_Y, WORLD_HEIGHT, NUM_SECTIONS +from .client import Client +from .modjar import ModInfo, scan_mods, summarize +from .version import VersionInfo, list_versions, resolve_version +from .world import World + +# 默认的内容根(libraries/assets 可共享,可只读) +DEFAULT_CONTENT_ROOTS = ( + "/home/newqqagent/mcview/store", + "/home/newqqagent/plugindev/mcfold/store", +) + +# 内置的方块数据(minecraft-data 的 blocks.json,按 majorVersion 组织) +DEFAULT_DATA_ROOT = ( + "/home/newqqagent/plugin_data/mc/runtime/node_modules/" + "minecraft-data/minecraft-data/data/pc" +) + + +@dataclass +class InstanceStatus: + """给 agent 看的实例现状(含每一项的可信度)。""" + path: str + version_id: str = "" + mc_version: str = "" + loader: str = "" + protocol: int = 0 + mods_loaded: int = 0 + mods_failed: int = 0 + mod_blocks: int = 0 + connected: bool = False + loaded_chunks: int = 0 + unknown_states: int = 0 + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "path": self.path, + "version": { + "id": self.version_id, "mc": self.mc_version, + "loader": self.loader, "protocol": self.protocol, + "trusted": "来自 version.json(含 inheritsFrom 归一化)", + }, + "mods": { + "loaded": self.mods_loaded, "failed": self.mods_failed, + "block_names": self.mod_blocks, + "state_ids": None, + "note": "只有方块名,无 state id(附录 D)", + }, + "world": { + "connected": self.connected, + "loaded_chunks": self.loaded_chunks, + "unknown_states": self.unknown_states, + }, + "notes": self.notes, + } + + +class Instance: + """一个 MC 实例。""" + + def __init__( + self, + path: str | os.PathLike[str], + prefer_version: str | None = None, + content_root: str | os.PathLike[str] | None = None, + data_root: str | os.PathLike[str] | None = None, + ): + self.path = Path(path) + self.prefer_version = prefer_version + self.content_root = Path(content_root) if content_root else self._find_content_root() + self.data_root = Path(data_root) if data_root else Path(DEFAULT_DATA_ROOT) + + self.version: VersionInfo | None = None + self.mods: list[ModInfo] = [] + self.registry: BlockRegistry | None = None + self.world = World() + self.client: Client | None = None + self.notes: list[str] = [] + + # ---------- 内容根 ---------- + def _find_content_root(self) -> Path | None: + """找可共享的内容根(含 libraries/ 的那个)。 + + 实例文件夹自己带 libraries/ 时优先用它(自包含安装)。 + """ + if (self.path / "libraries").is_dir(): + return self.path + for c in DEFAULT_CONTENT_ROOTS: + cp = Path(c) + if cp.is_dir(): + # 内容根下按 loader-version 组织 + for sub in cp.iterdir(): + if sub.is_dir() and (sub / "libraries").is_dir(): + return sub + return None + + # ---------- 装载 ---------- + def load(self) -> "Instance": + """读实例文件夹:版本 + mod + 方块注册表。""" + self.version = resolve_version(self.path, self.prefer_version) + if self.version is None: + raise FileNotFoundError( + f"{self.path} 下没有可用版本(需要 versions//.json)") + + mods_dir = self.path / "mods" + self.mods = scan_mods(mods_dir) + + self._load_registry() + self._build_world() + + if self.mods: + bad = [m for m in self.mods if not m.ok] + self.notes.append( + f"本实例含 {len(self.mods)} 个 mod" + + (f"({len(bad)} 个解析失败)" if bad else "") + + ";mod 方块仅有名字、无 state id," + "世界查询遇到未注册 state id 会显示 unknown(id)。" + ) + return self + + def _load_registry(self) -> None: + """载入方块注册表。 + + 注意 minecraft-data 的组织方式:1.20.1 目录里只有 version.json + (majorVersion=1.20),真正的数据在 1.20/ 下 —— 所以要按 majorVersion 找。 + """ + if self.version is None: + return + mc = self.version.mc_version + cands = [self.data_root / mc / "blocks.json"] + # 按 majorVersion 回退(1.20.1 -> 1.20) + parts = mc.split(".") + for take in range(len(parts) - 1, 0, -1): + cands.append(self.data_root / ".".join(parts[:take]) / "blocks.json") + for c in cands: + if c.is_file(): + self.registry = BlockRegistry.from_json_file(str(c)) + return + self.notes.append(f"未找到 {mc} 的方块数据 ⇒ 世界查询将只返回 state id") + + def _build_world(self) -> None: + self.world = World(self.registry) + if self.version is not None: + base = self.version.raw or {} + # 1.18+ 的世界范围可由 version.json 提供;缺省用 1.20 的实测值 + self.world.min_y = base.get("minY", MIN_Y) + # worldHeight 常见写法:height 或 直接用默认 384 + self.world.num_sections = base.get("worldHeight", WORLD_HEIGHT) >> 4 + + # ---------- 连接 ---------- + def connect(self, host: str, port: int, username: str) -> Client: + """连服务端(协议版本由实例文件夹决定,不硬编码)。""" + if self.version is None: + self.load() + assert self.version is not None + self.client = Client(host, port, username, self.version.mc_version, + protocol_path=self.data_root) + self.client.load_protocol() + return self.client + + # ---------- 能力查询 ---------- + def mod_block_names(self) -> list[str]: + """本实例 mod 提供的方块名(带命名空间)。""" + out: list[str] = [] + for m in self.mods: + if m.ok: + out.extend(m.qualified_blocks()) + return sorted(set(out)) + + def knows_block_name(self, name: str) -> bool: + """该方块名是否可用(原版注册表里有,或来自已装 mod)。 + + 用途:在 agent 下指令前校验,避免"你说的这个方块这服没有"。 + + 命名空间处理(实测需求):agent 常常只说 `cask` 而不是 + `expandeddelight:cask`。所以无命名空间时也去 mod 清单里找: + * 唯一命中 ⇒ 认识 + * 多个 mod 都有同名方块 ⇒ **不**当成认识(歧义,应当让 agent 说全名) + """ + if self.registry: + bare = name.split(":", 1)[1] if ":" in name else name + if bare in self.registry.by_name: + return True + + if ":" in name: + return name in set(self.mod_block_names()) + + # 无命名空间:在 mod 方块名的「裸名」里查,但要歧义敏感 + return len(self._mod_hits_bare(name)) == 1 + + def _mod_hits_bare(self, bare: str) -> list[str]: + """裸名 → 命中的完整限定名列表(用于歧义判定)。""" + hits: list[str] = [] + for m in self.mods: + if m.ok and bare in m.block_names: + hits.extend(m.qualified_blocks_for(bare)) + return hits + + def expand_block_name(self, name: str) -> str | None: + """把裸名展开为完整限定名;歧义或无命中返回 None。""" + if ":" in name: + return name if name in set(self.mod_block_names()) else None + hits = self._mod_hits_bare(name) + return hits[0] if len(hits) == 1 else None + + def resolve_block_name(self, state_id: int) -> str: + """state id → 名字,带 mod 归因(拿不到名字时明说是可能来自 mod)。""" + if self.registry is None: + return f"unknown({state_id})" + n = self.registry.name_of(state_id) + if n is not None: + return n + if self.mods: + return (f"unknown({state_id}) —— 本机注册表无此 state id;" + f"本实例有 {len([m for m in self.mods if m.ok])} 个 mod," + f"极可能是 mod 方块") + return f"unknown({state_id})" + + def _protocol_of(self, mc_version: str) -> int: + """从 minecraft-data 的 version.json 读协议号(无需连接)。 + + 注意:传入的必须是**归一化后的** mc 版本(version.py 已保证)。 + 加载器版本的 id(如 1.20.1-forge-47.4.0)在这里查不到, + 所以调用方先用 VersionInfo.mc_version。 + """ + import json as _json + parts = mc_version.split(".") + cands = [self.data_root / mc_version / "version.json"] + # 按 majorVersion 回退(1.20.1 -> 1.20) + for take in range(len(parts) - 1, 0, -1): + cands.append(self.data_root / ".".join(parts[:take]) / "version.json") + for cand in cands: + if cand.is_file(): + try: + return int(_json.loads(cand.read_text()).get("version", 0)) + except Exception: + return 0 + return 0 + + # ---------- 摘要 ---------- + def status(self) -> InstanceStatus: + st = InstanceStatus(path=str(self.path)) + if self.version: + st.version_id = self.version.version_id + st.mc_version = self.version.mc_version + st.loader = self.version.loader + if self.client: + st.protocol = self.client.proto_num + elif self.version: + st.protocol = self._protocol_of(self.version.mc_version) + st.mods_loaded = len([m for m in self.mods if m.ok]) + st.mods_failed = len([m for m in self.mods if not m.ok]) + st.mod_blocks = len(self.mod_block_names()) + st.loaded_chunks = self.world.loaded_count + st.unknown_states = len(self.world.unknown_state_ids) + st.notes = list(self.notes) + return st + + def mods_summary(self) -> dict[str, Any]: + return summarize(self.mods) + + def world_summary(self) -> dict[str, Any]: + return self.world.summary() diff --git a/mcfold/modjar.py b/mcfold/modjar.py index ad984e6..d7753ef 100644 --- a/mcfold/modjar.py +++ b/mcfold/modjar.py @@ -54,8 +54,18 @@ class ModInfo: def qualified_blocks(self) -> list[str]: """带命名空间的方块名,形如 expandeddelight:cask。""" - ns = self.mod_id or (self.namespaces[0] if self.namespaces else "") - return [f"{ns}:{n}" for n in self.block_names] if ns else list(self.block_names) + return [f"{self.namespace}:{n}" for n in self.block_names] + + def qualified_blocks_for(self, block_name: str) -> list[str]: + """指定裸方块名在本 mod 下的完整限定名(不在则空)。""" + if block_name not in self.block_names: + return [] + return [f"{self.namespace}:{block_name}"] + + @property + def namespace(self) -> str: + """该 mod 的命名空间(通常 == mod_id)。""" + return self.mod_id or (self.namespaces[0] if self.namespaces else "") # ---------------- 元数据解析 ---------------- diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..219c4e2 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# 统一测试入口。用法: ./run_tests.sh +cd "$(dirname "$0")" +total=0; fail=0 +for t in tests/test_*.py; do + name=$(basename "$t") + out=$(python3 "$t" 2>&1) + if echo "$out" | grep -qE "全部通过|通过$"; then + n=$(echo "$out" | grep -c "^ ✓") + total=$((total+n)) + printf ' %-24s ✓ %2d 项\n' "$name" "$n" + else + fail=$((fail+1)) + printf ' %-24s ✗ 失败\n' "$name" + echo "$out" | tail -5 | sed 's/^/ /' + fi +done +echo +if [ $fail -eq 0 ]; then + echo "全部通过:$total 项,0 失败" +else + echo "失败 $fail 个套件(共 $total 项通过)"; exit 1 +fi diff --git a/tests/test_instance.py b/tests/test_instance.py new file mode 100644 index 0000000..73aeea5 --- /dev/null +++ b/tests/test_instance.py @@ -0,0 +1,175 @@ +"""实例层回归:传入文件夹 → 拉起实例(版本 + mod + 方块表)。""" +import sys, os, json, zipfile, tempfile, shutil +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mcfold.instance import Instance +from mcfold.modjar import scan_mods + +BLOCKS_MOD = "/home/minecraft/mods/[扩充乐事] expandeddelight-0.1.4.jar" +FORGE_MOD = "/home/mc-test/mods/curtain-mc1.20.1-1.3.2.jar" +REAL_INST = "/home/newqqagent/mcview/store/vanilla-1.20.1" + +def build_instance(with_mods=True, version_id="1.20.1", mc=None): + """造一个临时实例文件夹(完整 MC 客户端安装形状)。 + + version_id 是目录名(可能是加载器 id);mc 是它继承的原版版本。 + """ + d = tempfile.mkdtemp(prefix="mcfold-inst-") + ver = version_id + vdir = os.path.join(d, "versions", ver) + os.makedirs(vdir, exist_ok=True) + obj = {"id": ver, "mainClass": "net.minecraft.client.main.Main", "assets": "5"} + if mc and mc != ver: + obj["inheritsFrom"] = mc + obj["mainClass"] = ("io.github.zekerzhayard.forgewrapper.installer.Main" + if "forge" in ver else + "net.fabricmc.loader.impl.launch.knot.KnotClient") + with open(os.path.join(vdir, f"{ver}.json"), "w") as f: + json.dump(obj, f) + os.makedirs(os.path.join(d, "mods"), exist_ok=True) + if with_mods: + for src in (BLOCKS_MOD, FORGE_MOD): + if os.path.isfile(src): + shutil.copy(src, os.path.join(d, "mods")) + return d + +def test_load_from_folder(): + """★ 用户口径:传入文件夹路径 → 拉起实例。""" + d = build_instance() + try: + inst = Instance(d).load() + st = inst.status() + assert st.mc_version == "1.20.1", st.mc_version + assert st.protocol == 763, st.protocol + assert st.loader == "vanilla", st.loader + print(f" ✓ 传入文件夹拉起实例: {st.version_id} mc={st.mc_version} 协议={st.protocol}") + finally: + shutil.rmtree(d, ignore_errors=True) + +def test_mods_registered(): + """★ 读取 mod jar 注册方块表。""" + d = build_instance() + try: + inst = Instance(d).load() + ms = inst.mods_summary() + assert ms["loaded"] == 2, ms + assert ms["block_name_count"] > 20, ms + names = inst.mod_block_names() + assert "expandeddelight:cask" in names, names[:5] + # 诚实边界必须体现在摘要里 + assert "state id" in ms["note"] + print(f" ✓ mod 注册: {ms['loaded']} 个 mod, {ms['block_name_count']} 个方块名") + finally: + shutil.rmtree(d, ignore_errors=True) + +def test_loader_version_normalization(): + """forge 实例:mc_version 必须归一化(否则协议选错)。""" + d = build_instance(version_id="1.20.1-forge-47.4.0", mc="1.20.1") + try: + inst = Instance(d, prefer_version="1.20.1-forge-47.4.0").load() + st = inst.status() + assert st.mc_version == "1.20.1", st.mc_version + assert st.loader == "forge", st.loader + assert st.protocol == 763, st.protocol + print(f" ✓ forge 归一化: id={st.version_id} → mc={st.mc_version} 协议={st.protocol}") + finally: + shutil.rmtree(d, ignore_errors=True) + +def test_block_name_validation(): + """方块名校验:裸名可展开;歧义不误判。""" + d = build_instance() + try: + inst = Instance(d).load() + assert inst.knows_block_name("cask") + assert inst.expand_block_name("cask") == "expandeddelight:cask" + assert inst.knows_block_name("stone") + assert not inst.knows_block_name("definitely_not_a_block") + print(" ✓ 方块名校验: 裸名展开正确, 不存在的方块判否") + finally: + shutil.rmtree(d, ignore_errors=True) + +def test_ambiguous_bare_name(): + """两个 mod 有同名方块时,裸名必须判为**不可用**(歧义)。""" + d = build_instance(with_mods=False) + try: + for mid in ("moda", "modb"): + with zipfile.ZipFile(os.path.join(d, "mods", f"{mid}.jar"), "w") as zf: + zf.writestr("fabric.mod.json", + json.dumps({"id": mid, "name": mid, "version": "1"})) + zf.writestr(f"assets/{mid}/blockstates/shared_block.json", "{}") + inst = Instance(d).load() + assert not inst.knows_block_name("shared_block"), "有歧义却判为认识" + assert inst.knows_block_name("moda:shared_block"), "限定名应可用" + print(" ✓ 歧义处理: 裸名(两 mod 同名)判否, 限定名可判是") + finally: + shutil.rmtree(d, ignore_errors=True) + +def test_real_install_folder(): + """对真实安装目录验证(无 mod 的 vanilla)。""" + if not os.path.isdir(REAL_INST): + print(" - 真实安装目录不存在,跳过"); return + inst = Instance(REAL_INST).load() + st = inst.status() + assert st.mc_version == "1.20.1" and st.protocol == 763, (st.mc_version, st.protocol) + assert inst.registry is not None and len(inst.registry.blocks) > 900 + print(f" ✓ 真实安装: {st.version_id} 协议={st.protocol} 原版方块={len(inst.registry.blocks)}") + +def test_resolve_block_name_mod_attribution(): + """越界 state id 的报错必须**归因到 mod**(让降级可信)。""" + d = build_instance() + try: + inst = Instance(d).load() + msg = inst.resolve_block_name(99999) + assert msg.startswith("unknown("), msg + assert "mod" in msg, msg + print(f" ✓ 越界归因: {msg[:46]}…") + finally: + shutil.rmtree(d, ignore_errors=True) + +def test_e2e_connect_from_instance(): + """★ 端到端:实例文件夹 → 连服 → 登录(协议由文件夹决定,不硬编码)。""" + import socket, subprocess, time + HOST, PORT = "127.0.0.1", 25599 + runtime = "/home/newqqagent/plugin_data/mc/runtime" + def reachable(): + try: + socket.create_connection((HOST, PORT), timeout=2).close(); return True + except OSError: + return False + proc = None + if not reachable(): + src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "support_testserver.js") + if not os.path.isfile(src): + print(" - 无 support_testserver.js,跳过"); return + shutil.copy(src, runtime) + proc = subprocess.Popen(["node", "support_testserver.js", str(PORT)], cwd=runtime, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for _ in range(50): + if reachable(): break + time.sleep(0.2) + if not reachable(): + print(" - 服务端不可用,跳过") + if proc: proc.kill() + return + d = build_instance() + try: + inst = Instance(d).load() + c = inst.connect(HOST, PORT, "McfoldBot") + r = c.run_smoke() + assert r["protocol"] == 763 and r["login_ok"], r + print(f" ✓ 端到端: 文件夹→协议{r['protocol']}→登录成功 (mod {len(inst.mods)} 个)") + c.close() + finally: + shutil.rmtree(d, ignore_errors=True) + if proc: + proc.terminate() + try: proc.wait(timeout=5) + except Exception: proc.kill() + try: os.remove(os.path.join(runtime, "support_testserver.js")) + except OSError: pass + +if __name__ == "__main__": + for fn in [test_load_from_folder, test_mods_registered, test_loader_version_normalization, + test_block_name_validation, test_ambiguous_bare_name, test_real_install_folder, + test_resolve_block_name_mod_attribution, test_e2e_connect_from_instance]: + fn() + print("\n实例层:全部通过")