Files
agentmc/mcfold/server.py
JianFeeeee 480fe8e225 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 失败
2026-09-26 11:12:40 +08:00

301 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""空白测试服的起停与探活(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))