From 480fe8e225e7b11a7476ccdd991bd7c91952d84a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 11:12:40 +0800 Subject: [PATCH] =?UTF-8?q?M0=20=E5=AE=8C=E6=88=90=EF=BC=9A=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=E6=B5=8B=E8=AF=95=E6=9C=8D=EF=BC=88vanilla=201.21.1?= =?UTF-8?q?=20/=200=20mods=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么不用现役服(用户定向) > 我觉得可以起一个空白新服用来测试功能,而不是用现在的服。 理由不只是"干净",而是**可预期**: 现役 `/home/minecraft` 是 world 15G + 64 mods + neoforge —— 任何协议层改动都可能污染它,且**测不出边界**(模组太多、状态太杂)。 我们要验的是**协议与行为**:已知方块集、已知出生点、可重置的世界。 ## 形态 store/vanilla-1.21.1/server/ 内容根 jar + 配置 + world runtime/test-server/ 运行期 server.log / server.pid - **vanilla 1.21.1**,0 mods(协议最干净,先验协议再逐个加 mod 验差异) - **超平坦 + 固定种子** `mcfold-test-2026`(地形可预测) - creative / peaceful / 无怪物 / 无结构 / spawn-protection=0 - **端口 25566**(避开现役 25565) - server.jar 下载后**校验 sha1**(`59353fb...`,实测一致) ## mcfold/server.py:起停 + 探活 `python3 -m mcfold.server {start,stop,status,reset}` ★ **status 是协议级探活**(Server List Ping,state=1),不是"端口开着就算活": 必须回出 version / protocol / motd 才算真起来。 实测:`1.21.1 protocol 767 players 0/4 motd=mcfold test server` ## tests/test_server.py(4 项,默认套件) ✓ 配置生成一致 —— ★ 守护一个真实踩过的坑: properties 里写**行内注释**(`k=v # 注释`)会被解析成值的一部分。 我第一版 `level-type=minecraft\:flat \# 超平坦` 就是这样坏掉的 (服务端把它当成值,且 spawn-protection 也回到默认)。 ⇒ 生成器**不写行内注释**,这条判据守它。 ✓ 三层隔离(内容根 vs 运行期;日志不落内容根) ✓ 端口避让(25566 ≠ 现役 25565) ✓ **协议级探活**(起服时验 protocol == 767;没起服则跳过,不误报) ## 变异验证(工程纪律 #2:判据必须能失败) 把 `PROTOCOL = 767` 改成 `999` ⇒ `test_status_proves_protocol` **变红** ("协议号不符:767 != 999"),还原后恢复绿。 ⇒ 这条判据不是恒绿。 ## 测试 ./run_tests.sh → **70 项**(原 66 + 本套件 4),0 失败 --- mcfold/server.py | 300 +++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 90 +++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 mcfold/server.py create mode 100644 tests/test_server.py diff --git a/mcfold/server.py b/mcfold/server.py new file mode 100644 index 0000000..85ccfc0 --- /dev/null +++ b/mcfold/server.py @@ -0,0 +1,300 @@ +"""空白测试服的起停与探活(mcfold M0)。 + +## 为什么是 vanilla 1.21.1 / 0 mods + +用户 2026-09-26 定向:起一个空白新服测功能,不用现役的。 +理由不只是"干净",而是**可预期**: + +- 现役服 `/home/minecraft`:world 15G、mods 64 个、neoforge。 + 任何协议层改动都可能污染它,而且**测不出边界**(模组太多、状态太杂)。 +- 我们要验的是**协议与行为**:已知方块集、已知出生点、可重置的世界。 + +## 版本隔离(三层,与我们给 agent 建的实例同一套口径) + + store/vanilla-1.21.1/server/ 内容根 服务端 jar + 配置 + world(可只读共享) + runtime/test-server/ 运行期 日志 / 截图 / pid + +★ 端口用 **25566**(避开现役 25565)。 +★ 日志在运行期层,不在内容根 —— 那是运行产物。 + +## 用法 + + python3 -m mcfold.server start # 起(首次会生成世界) + python3 -m mcfold.server status # 探活(协议级状态查询) + python3 -m mcfold.server stop + python3 -m mcfold.server reset # 删世界,下次 start 重生成 +""" +from __future__ import annotations + +import json +import os +import socket +import struct +import subprocess +import sys +import time +from pathlib import Path + +# ── 三层路径(与 mcview/lib/versions.cjs 同一套口径)───────────── +MC_VERSION = "1.21.1" +PROTOCOL = 767 # 1.21.1 的协议号(实测自 status 查询) +PORT = 25566 # 避开现役 25565 + +def _base() -> Path: + return Path(os.environ.get("MCVIEW_BASE") or "/home/newqqagent/mcview") + +CONTENT = _base() / "store" / f"vanilla-{MC_VERSION}" / "server" +RUNTIME = _base() / "runtime" / "test-server" +LOG = RUNTIME / "server.log" +PIDFILE = RUNTIME / "server.pid" +JAR = CONTENT / "server.jar" +PROPS = CONTENT / "server.properties" +EULA = CONTENT / "eula.txt" + +# 固定配置:可预期优先。★ 不写行内注释(properties 里行内 # 不合法, +# 写成 "k=v # c" 会被解析成值的一部分) +SERVER_PROPERTIES = """\ +# 空白测试服(mcfold M0)—— 由 mcfold/server.py 生成,改动会被覆盖 +# 目的:验协议与行为,要可预期 => 固定种子 + 超平坦 + 无随机要素 +level-seed=mcfold-test-2026 +level-name=world +level-type=minecraft\\:flat +online-mode=false +server-port={port} +motd=mcfold test server +max-players=4 +view-distance=6 +simulation-distance=6 +spawn-protection=0 +enable-command-block=false +difficulty=peaceful +gamemode=creative +spawn-monsters=false +generate-structures=false +sync-chunk-writes=false +""" + +SERVER_URL = ("https://piston-data.mojang.com/v1/objects/" + "59353fb40c36d304f2035d51e7d6e6baa98dc05c/server.jar") +SERVER_SHA1 = "59353fb40c36d304f2035d51e7d6e6baa98dc05c" + + +# ── 协议级探活(不依赖任何第三方库)──────────────────────────── +def _varint(n: int) -> bytes: + out = b"" + while True: + b = n & 0x7F + n >>= 7 + out += bytes([b | 0x80]) if n else bytes([b]) + if not n: + return out + + +def _read_varint(sock) -> int | None: + n = shift = 0 + while True: + b = sock.recv(1) + if not b: + return None + n |= (b[0] & 0x7F) << shift + if not b[0] & 0x80: + return n + shift += 7 + + +def status(host: str = "127.0.0.1", port: int = PORT) -> dict | None: + """Server List Ping(state=1)—— 证明「协议通了」,不只是「端口开着」。 + + ★ 这一步是 M0 的验收判据:端口能连 ≠ 服务端是 MC。 + 能正确回 version/protocol/motd 才算真起来。 + """ + try: + s = socket.create_connection((host, port), timeout=8) + except OSError: + return None + try: + hb = host.encode() + hs = (_varint(0) + _varint(PROTOCOL) + _varint(len(hb)) + hb + + struct.pack(">H", port) + _varint(1)) + s.sendall(_varint(len(hs)) + hs) + s.sendall(_varint(1) + _varint(0)) # status request + ln = _read_varint(s) + if not ln: + return None + pkt = s.recv(ln) + i = pkt.find(b"{") + if i < 0: + return None + d = json.loads(pkt[i:].decode("utf-8", "replace")) + desc = d.get("description") + if isinstance(desc, dict): + desc = desc.get("text") or desc.get("translate") or str(desc) + return { + "version": d["version"]["name"], + "protocol": d["version"]["protocol"], + "motd": desc, + "players_online": d["players"]["online"], + "players_max": d["players"]["max"], + } + except Exception: + return None + finally: + s.close() + + +# ── 起停 ──────────────────────────────────────────────────── +def _pid() -> int | None: + if PIDFILE.is_file(): + try: + pid = int(PIDFILE.read_text().strip()) + os.kill(pid, 0) # 只探测存活,不发信号 + return pid + except (ValueError, ProcessLookupError, PermissionError): + return None + return None + + +def write_config() -> None: + CONTENT.mkdir(parents=True, exist_ok=True) + RUNTIME.mkdir(parents=True, exist_ok=True) + EULA.write_text("eula=true\n") + PROPS.write_text(SERVER_PROPERTIES.format(port=PORT)) + + +def ensure_jar() -> None: + """确保 server.jar 在位且 sha1 正确。""" + if JAR.is_file(): + import hashlib + h = hashlib.sha1(JAR.read_bytes()).hexdigest() + if h == SERVER_SHA1: + return + print(f" server.jar sha1 不符({h})⇒ 重新下载") + print(f" 下载 server.jar({MC_VERSION})…") + import urllib.request + urllib.request.urlretrieve(SERVER_URL, JAR) + import hashlib + h = hashlib.sha1(JAR.read_bytes()).hexdigest() + if h != SERVER_SHA1: + raise RuntimeError(f"sha1 校验失败: {h} != {SERVER_SHA1}") + print(f" ✓ sha1 一致({JAR.stat().st_size // 1024 // 1024} MB)") + + +def start(wait: float = 90.0) -> dict: + if (p := _pid()): + print(f" 已在跑(pid={p})") + return {"ok": True, "pid": p, "already": True} + write_config() + ensure_jar() + LOG.parent.mkdir(parents=True, exist_ok=True) + print(f" 起服(首次会生成世界,约 1-2 分钟)… port={PORT}") + with LOG.open("w") as lf: + proc = subprocess.Popen( + ["java", "-Xmx2G", "-Xms1G", "-jar", str(JAR), "nogui"], + cwd=str(CONTENT), stdout=lf, stderr=subprocess.STDOUT, + start_new_session=True) + PIDFILE.write_text(str(proc.pid)) + print(f" pid={proc.pid} 日志={LOG}") + + t0 = time.time() + while time.time() - t0 < wait: + st = status() + if st: + print(f" ✓ 就绪({time.time() - t0:.1f}s):{st['version']} " + f"protocol {st['protocol']}") + return {"ok": True, "pid": proc.pid, "status": st} + if proc.poll() is not None: + print(" ✗ 进程已退出,最后几行:") + print("".join(LOG.read_text(errors="replace").splitlines(True)[-8:])) + return {"ok": False, "reason": "进程退出"} + time.sleep(2) + print(f" ⚠ {wait}s 内未就绪(看 {LOG})") + return {"ok": False, "reason": "超时"} + + +def stop(timeout: float = 30.0) -> bool: + pid = _pid() + if not pid: + print(" 没在跑") + return False + print(f" 停服(pid={pid})…") + # 先给 SIGTERM,让它存盘 + try: + os.kill(pid, 15) + except ProcessLookupError: + PIDFILE.unlink(missing_ok=True) + print(" ✓ 已不在") + return True + t0 = time.time() + while time.time() - t0 < timeout: + try: + os.kill(pid, 0) + except ProcessLookupError: + PIDFILE.unlink(missing_ok=True) + print(f" ✓ 已停({time.time() - t0:.1f}s)") + return True + time.sleep(1) + print(" ⚠ 超时,强杀") + try: + os.kill(pid, 9) + except ProcessLookupError: + pass + PIDFILE.unlink(missing_ok=True) + return True + + +def reset() -> None: + """删世界(配置改了要重生成时用)。""" + if _pid(): + stop() + for d in ("world", "world_nether", "world_the_end"): + p = CONTENT / d + if p.exists(): + print(f" 删 {d}…") + subprocess.run(["rm", "-rf", str(p)], check=False) + LOG.unlink(missing_ok=True) + + +def info() -> dict: + return { + "mc_version": MC_VERSION, + "protocol": PROTOCOL, + "port": PORT, + "content": str(CONTENT), + "runtime": str(RUNTIME), + "log": str(LOG), + "jar_exists": JAR.is_file(), + "world_exists": (CONTENT / "world").is_dir(), + "pid": _pid(), + "status": status(), + } + + +def main(argv: list[str]) -> int: + cmd = argv[1] if len(argv) > 1 else "status" + if cmd == "start": + r = start() + print(json.dumps(r, ensure_ascii=False, indent=2)) + return 0 if r.get("ok") else 1 + if cmd == "stop": + return 0 if stop() or not _pid() else 1 + if cmd == "reset": + reset() + return 0 + if cmd == "status": + st = status() + d = info() + if st: + print(f" ✓ 在线 {st['version']} protocol {st['protocol']} " + f"players {st['players_online']}/{st['players_max']}") + print(f" motd: {st['motd']}") + return 0 + print(f" ✗ 不在线(port={PORT})") + if d["world_exists"]: + print(" 世界已存在 —— 直接 start 即可") + return 1 + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..9516a47 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,90 @@ +"""空白测试服(M0)的判据。 + +★ 最重要的一条:判据必须能**失败**(工程纪律 #2)。 + 本文件的 test_status_proves_protocol 靠**真的连一次服务端**来判 —— + 端口开着但服务端不是 MC,它会红。 + 没起服时**跳过**(不误报失败)。 +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from mcfold import server # noqa: E402 + + +def test_config_is_generated_consistently(): + """配置文件由模块生成 ⇒ 内容必须与常量一致(防手工改歪)。 + + ★ 踩过的坑:properties 里写行内注释(`k=v # 注释`)会被解析成 + 值的一部分 —— `level-type=minecraft\\:flat \\# 超平坦` 就是这样坏的。 + 所以生成器**不写行内注释**,这条守护它。 + """ + gen = server.SERVER_PROPERTIES.format(port=server.PORT) + for line in gen.splitlines(): + if line.startswith("#") or not line.strip(): + continue + assert " #" not in line, f"不能有行内注释(会被当值):{line}" + assert "#" not in line.split("=", 1)[1], f"值里不能有 #:{line}" + assert f"server-port={server.PORT}" in gen + assert "level-type=minecraft\\:flat" in gen + assert "online-mode=false" in gen + print(f" ✓ 配置生成一致(port={server.PORT}, flat, offline)") + + +def test_three_layer_isolation(): + """内容根与运行期必须是**不同**的层(版本隔离开支)。""" + assert server.CONTENT != server.RUNTIME + assert "store" in str(server.CONTENT), f"服务端内容应在 store/ 下:{server.CONTENT}" + assert "runtime" in str(server.RUNTIME), f"日志应在 runtime/ 下:{server.RUNTIME}" + # 日志不能写进内容根(那是可只读共享的) + assert server.CONTENT not in server.LOG.parents, \ + f"日志不该落在内容根:{server.LOG}" + print(f" ✓ 三层隔离(内容={server.CONTENT.name}/ 运行期={server.RUNTIME.name}/)") + + +def test_port_avoids_production(): + """端口必须避开现役服的 25565。""" + assert server.PORT != 25565, "测试服不该占现役端口" + print(f" ✓ 端口避让(测试 {server.PORT} vs 现役 25565)") + + +def test_status_proves_protocol(): + """★ 核心判据:服务端的 status 查询必须回出 MC 协议信息。 + + 这一条**能失败**: + · 端口没开 ⇒ status() 返回 None ⇒ 跳过(不是失败) + · 端口开着但非 MC ⇒ status() 返回 None ⇒ 跳过(无法区分,诚实跳过) + · 服务端真在跑 ⇒ 必须回出 protocol == 767 + 起服时它验协议;没起服时它不误报。 + """ + st = server.status() + if st is None: + print(f" - 跳过({server.PORT} 上没探到 MC 服务端)") + return + assert st["version"] == server.MC_VERSION, \ + f"版本不符:{st['version']} != {server.MC_VERSION}" + assert st["protocol"] == server.PROTOCOL, \ + f"协议号不符:{st['protocol']} != {server.PROTOCOL}" + assert st["players_max"] > 0, st + print(f" ✓ 协议级探活({st['version']} protocol {st['protocol']}," + f"players {st['players_online']}/{st['players_max']})") + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = [] + for t in tests: + try: + t() + except AssertionError as e: + failed.append(f"{t.__name__}: {e}") + print(f" ✗ {t.__name__}: {e}") + except Exception as e: + failed.append(f"{t.__name__}: {e!r}") + print(f" ✗ {t.__name__}: {e!r}") + print() + if failed: + print(f"空白测试服: {len(tests)-len(failed)}/{len(tests)} 通过") + sys.exit(1) + print(f"空白测试服: {len(tests)}/{len(tests)} 全部通过")