naldo commited on
Commit ·
4772e29
1
Parent(s): fd476cd
Set 100 player limit and Bedrock owner operator
Browse files- minecraft_stack.py +58 -9
- tests/test_server_admin_config.py +60 -0
minecraft_stack.py
CHANGED
|
@@ -27,7 +27,9 @@ VIA_DIR = BASE / "viaproxy"
|
|
| 27 |
# in-place, so using this directory preserves both initial login and token rotation.
|
| 28 |
RELAY_DIR = PERSIST_ROOT / "relay"
|
| 29 |
WORLD_DIR = PERSIST_ROOT / "world-20260927-surface-v2"
|
| 30 |
-
BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot")
|
|
|
|
|
|
|
| 31 |
TOOLS_DIR = BASE / "tools"
|
| 32 |
BIN_DIR = BASE / "bin"
|
| 33 |
LOG_PATH = BASE / "stack.log"
|
|
@@ -194,6 +196,28 @@ def _relay_diagnostics(lines: list[str]) -> dict[str, Any]:
|
|
| 194 |
"backend_failures": sum("failed to dial backend" in line or "failed to log in to backend" in line
|
| 195 |
for line in events),
|
| 196 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
class MinecraftStack:
|
|
@@ -391,8 +415,9 @@ class MinecraftStack:
|
|
| 391 |
"allow-flight": "true",
|
| 392 |
"gamemode": "creative",
|
| 393 |
"force-gamemode": "true",
|
| 394 |
-
"difficulty": "peaceful",
|
| 395 |
-
"max-players":
|
|
|
|
| 396 |
"view-distance": "8",
|
| 397 |
"simulation-distance": "6",
|
| 398 |
"level-name": "world",
|
|
@@ -400,11 +425,12 @@ class MinecraftStack:
|
|
| 400 |
"generate-structures": "true",
|
| 401 |
"motd": "Voyager AI + Bedrock Friends",
|
| 402 |
}
|
| 403 |
-
(JAVA_DIR / "server.properties").write_text(
|
| 404 |
-
"\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
|
| 405 |
-
encoding="utf-8",
|
| 406 |
-
)
|
| 407 |
-
|
|
|
|
| 408 |
return launcher
|
| 409 |
|
| 410 |
def _start_fabric(self) -> None:
|
|
@@ -548,7 +574,7 @@ class MinecraftStack:
|
|
| 548 |
"nethernet_backend_address": "http://127.0.0.1:19134",
|
| 549 |
"host_name": HOST_NAME,
|
| 550 |
"world_name": WORLD_NAME,
|
| 551 |
-
"max_players":
|
| 552 |
"fake_player_count": 0,
|
| 553 |
"protocol": BEDROCK_PROTOCOL,
|
| 554 |
"version": BEDROCK_VERSION,
|
|
@@ -638,6 +664,26 @@ class MinecraftStack:
|
|
| 638 |
all_lines = []
|
| 639 |
server_lines = []
|
| 640 |
relay_health = _relay_diagnostics(all_lines[boot:] if all_lines else [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
offline_uuid = uuid.UUID(bytes=hashlib.md5(
|
| 642 |
f"OfflinePlayer:{BOT_USERNAME}".encode("utf-8")
|
| 643 |
).digest(), version=3)
|
|
@@ -682,6 +728,9 @@ class MinecraftStack:
|
|
| 682 |
"world_persistence": str(WORLD_DIR),
|
| 683 |
"player_save": player_save,
|
| 684 |
"relay_health": relay_health,
|
|
|
|
|
|
|
|
|
|
| 685 |
"bot_username": BOT_USERNAME,
|
| 686 |
"bot_display_name": "Naldo Bot",
|
| 687 |
"bedrock_protocol": BEDROCK_PROTOCOL,
|
|
|
|
| 27 |
# in-place, so using this directory preserves both initial login and token rotation.
|
| 28 |
RELAY_DIR = PERSIST_ROOT / "relay"
|
| 29 |
WORLD_DIR = PERSIST_ROOT / "world-20260927-surface-v2"
|
| 30 |
+
BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot")
|
| 31 |
+
ADMIN_USERNAME = "IffyBinkie6134"
|
| 32 |
+
MAX_PLAYERS = 100
|
| 33 |
TOOLS_DIR = BASE / "tools"
|
| 34 |
BIN_DIR = BASE / "bin"
|
| 35 |
LOG_PATH = BASE / "stack.log"
|
|
|
|
| 196 |
"backend_failures": sum("failed to dial backend" in line or "failed to log in to backend" in line
|
| 197 |
for line in events),
|
| 198 |
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _offline_player_uuid(username: str) -> str:
|
| 202 |
+
return str(uuid.UUID(bytes=hashlib.md5(
|
| 203 |
+
f"OfflinePlayer:{username}".encode("utf-8")
|
| 204 |
+
).digest(), version=3))
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _ensure_admin_operator(path: Path, username: str) -> None:
|
| 208 |
+
"""Seed the exact offline-mode Java profile used by the Bedrock relay."""
|
| 209 |
+
if not re.fullmatch(r"[A-Za-z0-9_]{1,16}", username):
|
| 210 |
+
raise ValueError("Invalid Minecraft operator username")
|
| 211 |
+
operators = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
|
| 212 |
+
if not isinstance(operators, list):
|
| 213 |
+
raise ValueError("Minecraft ops.json must be a list")
|
| 214 |
+
identity = _offline_player_uuid(username)
|
| 215 |
+
entry = {"uuid": identity, "name": username, "level": 4, "bypassesPlayerLimit": True}
|
| 216 |
+
preserved = [item for item in operators if not (
|
| 217 |
+
isinstance(item, dict) and (item.get("uuid") == identity or item.get("name") == username)
|
| 218 |
+
)]
|
| 219 |
+
preserved.append(entry)
|
| 220 |
+
path.write_text(json.dumps(preserved, indent=2) + "\n", encoding="utf-8")
|
| 221 |
|
| 222 |
|
| 223 |
class MinecraftStack:
|
|
|
|
| 415 |
"allow-flight": "true",
|
| 416 |
"gamemode": "creative",
|
| 417 |
"force-gamemode": "true",
|
| 418 |
+
"difficulty": "peaceful",
|
| 419 |
+
"max-players": str(MAX_PLAYERS),
|
| 420 |
+
"op-permission-level": "4",
|
| 421 |
"view-distance": "8",
|
| 422 |
"simulation-distance": "6",
|
| 423 |
"level-name": "world",
|
|
|
|
| 425 |
"generate-structures": "true",
|
| 426 |
"motd": "Voyager AI + Bedrock Friends",
|
| 427 |
}
|
| 428 |
+
(JAVA_DIR / "server.properties").write_text(
|
| 429 |
+
"\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
|
| 430 |
+
encoding="utf-8",
|
| 431 |
+
)
|
| 432 |
+
_ensure_admin_operator(JAVA_DIR / "ops.json", ADMIN_USERNAME)
|
| 433 |
+
self._ensure_persistent_world()
|
| 434 |
return launcher
|
| 435 |
|
| 436 |
def _start_fabric(self) -> None:
|
|
|
|
| 574 |
"nethernet_backend_address": "http://127.0.0.1:19134",
|
| 575 |
"host_name": HOST_NAME,
|
| 576 |
"world_name": WORLD_NAME,
|
| 577 |
+
"max_players": MAX_PLAYERS,
|
| 578 |
"fake_player_count": 0,
|
| 579 |
"protocol": BEDROCK_PROTOCOL,
|
| 580 |
"version": BEDROCK_VERSION,
|
|
|
|
| 664 |
all_lines = []
|
| 665 |
server_lines = []
|
| 666 |
relay_health = _relay_diagnostics(all_lines[boot:] if all_lines else [])
|
| 667 |
+
try:
|
| 668 |
+
operators = json.loads((JAVA_DIR / "ops.json").read_text(encoding="utf-8"))
|
| 669 |
+
expected_uuid = _offline_player_uuid(ADMIN_USERNAME)
|
| 670 |
+
admin_operator = any(
|
| 671 |
+
isinstance(entry, dict) and entry.get("uuid") == expected_uuid
|
| 672 |
+
and entry.get("name") == ADMIN_USERNAME and entry.get("level") == 4
|
| 673 |
+
for entry in operators
|
| 674 |
+
)
|
| 675 |
+
except (OSError, ValueError, TypeError):
|
| 676 |
+
admin_operator = False
|
| 677 |
+
admin_player_file_exists = (WORLD_DIR / "playerdata" / f"{_offline_player_uuid(ADMIN_USERNAME)}.dat").exists()
|
| 678 |
+
try:
|
| 679 |
+
properties = (JAVA_DIR / "server.properties").read_text(encoding="utf-8")
|
| 680 |
+
java_player_limit = int(re.search(r"(?m)^max-players=(\d+)$", properties).group(1))
|
| 681 |
+
except (OSError, AttributeError, ValueError):
|
| 682 |
+
java_player_limit = None
|
| 683 |
+
try:
|
| 684 |
+
xbox_player_limit = int(json.loads((RELAY_DIR / "config.json").read_text(encoding="utf-8"))["max_players"])
|
| 685 |
+
except (OSError, ValueError, KeyError, TypeError):
|
| 686 |
+
xbox_player_limit = None
|
| 687 |
offline_uuid = uuid.UUID(bytes=hashlib.md5(
|
| 688 |
f"OfflinePlayer:{BOT_USERNAME}".encode("utf-8")
|
| 689 |
).digest(), version=3)
|
|
|
|
| 728 |
"world_persistence": str(WORLD_DIR),
|
| 729 |
"player_save": player_save,
|
| 730 |
"relay_health": relay_health,
|
| 731 |
+
"player_limit": {"java": java_player_limit, "xbox_friends": xbox_player_limit},
|
| 732 |
+
"admin_operator": admin_operator,
|
| 733 |
+
"admin_player_file_exists": admin_player_file_exists,
|
| 734 |
"bot_username": BOT_USERNAME,
|
| 735 |
"bot_display_name": "Naldo Bot",
|
| 736 |
"bedrock_protocol": BEDROCK_PROTOCOL,
|
tests/test_server_admin_config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import tempfile
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import minecraft_stack as mc
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_operator_seed_preserves_other_operators():
|
| 9 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 10 |
+
path = Path(tmp) / "ops.json"
|
| 11 |
+
other = {"uuid": "other", "name": "ExistingAdmin", "level": 3, "bypassesPlayerLimit": False}
|
| 12 |
+
path.write_text(json.dumps([other]), encoding="utf-8")
|
| 13 |
+
mc._ensure_admin_operator(path, "IffyBinkie6134")
|
| 14 |
+
mc._ensure_admin_operator(path, "IffyBinkie6134")
|
| 15 |
+
entries = json.loads(path.read_text(encoding="utf-8"))
|
| 16 |
+
assert entries[0] == other
|
| 17 |
+
assert len(entries) == 2
|
| 18 |
+
assert entries[1] == {
|
| 19 |
+
"uuid": mc._offline_player_uuid("IffyBinkie6134"),
|
| 20 |
+
"name": "IffyBinkie6134", "level": 4, "bypassesPlayerLimit": True,
|
| 21 |
+
}
|
| 22 |
+
try:
|
| 23 |
+
mc._ensure_admin_operator(path, "bad name; op someone")
|
| 24 |
+
except ValueError:
|
| 25 |
+
pass
|
| 26 |
+
else:
|
| 27 |
+
raise AssertionError("invalid username accepted")
|
| 28 |
+
assert json.loads(path.read_text(encoding="utf-8")) == entries
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_status_reports_real_configured_limits_and_operator():
|
| 32 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 33 |
+
base = Path(tmp)
|
| 34 |
+
old = (mc.JAVA_DIR, mc.RELAY_DIR, mc.WORLD_DIR, mc.LOG_PATH)
|
| 35 |
+
try:
|
| 36 |
+
mc.JAVA_DIR, mc.RELAY_DIR = base / "java", base / "relay"
|
| 37 |
+
mc.WORLD_DIR, mc.LOG_PATH = base / "world", base / "stack.log"
|
| 38 |
+
mc.JAVA_DIR.mkdir()
|
| 39 |
+
mc.RELAY_DIR.mkdir()
|
| 40 |
+
mc.WORLD_DIR.mkdir()
|
| 41 |
+
(mc.JAVA_DIR / "server.properties").write_text("max-players=100\n", encoding="utf-8")
|
| 42 |
+
(mc.RELAY_DIR / "config.json").write_text('{"max_players":100}', encoding="utf-8")
|
| 43 |
+
mc._ensure_admin_operator(mc.JAVA_DIR / "ops.json", mc.ADMIN_USERNAME)
|
| 44 |
+
status = mc.MinecraftStack().status()
|
| 45 |
+
assert status["player_limit"] == {"java": 100, "xbox_friends": 100}
|
| 46 |
+
assert status["admin_operator"] is True
|
| 47 |
+
assert status["admin_player_file_exists"] is False
|
| 48 |
+
(mc.WORLD_DIR / "playerdata").mkdir()
|
| 49 |
+
(mc.WORLD_DIR / "playerdata" / f"{mc._offline_player_uuid(mc.ADMIN_USERNAME)}.dat").write_bytes(b"saved")
|
| 50 |
+
assert mc.MinecraftStack().status()["admin_player_file_exists"] is True
|
| 51 |
+
(mc.JAVA_DIR / "ops.json").write_text("[]", encoding="utf-8")
|
| 52 |
+
assert mc.MinecraftStack().status()["admin_operator"] is False
|
| 53 |
+
finally:
|
| 54 |
+
mc.JAVA_DIR, mc.RELAY_DIR, mc.WORLD_DIR, mc.LOG_PATH = old
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
test_operator_seed_preserves_other_operators()
|
| 59 |
+
test_status_reports_real_configured_limits_and_operator()
|
| 60 |
+
print("PASS player limit and persistent operator seed")
|