M0 完成:空白测试服(vanilla 1.21.1 / 0 mods)
## 为什么不用现役服(用户定向)
> 我觉得可以起一个空白新服用来测试功能,而不是用现在的服。
理由不只是"干净",而是**可预期**:
现役 `/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 失败
This commit is contained in:
300
mcfold/server.py
Normal file
300
mcfold/server.py
Normal file
@ -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))
|
||||
90
tests/test_server.py
Normal file
90
tests/test_server.py
Normal file
@ -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)} 全部通过")
|
||||
Reference in New Issue
Block a user