fix(transfer): use transferPlayer() beta API and enable experiments in level.dat

Switch from runCommand("transfer ...") to the @minecraft/server-admin
transferPlayer() function for reliable server-to-server transfers.
Enable Beta APIs experiment (gametest flag) in all 4 world level.dat files.
Add spawn protection to prevent transfer loops on arrival.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 02:34:29 +00:00
parent 2b0a0c4997
commit c12a468958
6 changed files with 165 additions and 13 deletions

View File

@@ -16,6 +16,16 @@ Four Bedrock servers on `10.0.0.247`, connected via native `/transfer` (no proxy
Players connect to `10.0.0.247:19132` (lobby) and use portal zones to transfer to child worlds.
Child worlds have a hub-return addon (compass, `!hub`/`!lobby` commands, portal zone) to transfer back.
### Transfer Mechanism
Transfers use `@minecraft/server-admin` beta API (`transferPlayer()` function). This requires:
- **Beta APIs experiment** enabled in each world's `level.dat` (`gametest` experiment flag)
- `@minecraft/server-admin` v1.0.0-beta dependency in manifest.json
- `permissions.json` allowing `@minecraft/server-admin`
The behavior packs are **bind-mounted** from the host project directory into Docker containers.
Deploy path on server: `/home/sysadmin/minecraft-multiworld/`
## Connection
The mc-ai-bridge runs at `10.0.0.247`:

View File

@@ -4,7 +4,7 @@
"name": "Hub Return Transfer",
"description": "Transfers players back to lobby when they step on the return portal",
"uuid": "b2c3d4e5-1111-2222-3333-fedcba654321",
"version": [1, 0, 1],
"version": [1, 0, 3],
"min_engine_version": [1, 21, 0]
},
"modules": [
@@ -12,14 +12,18 @@
"type": "script",
"language": "javascript",
"uuid": "b2c3d4e5-4444-5555-6666-fedcba987654",
"version": [1, 0, 1],
"version": [1, 0, 3],
"entry": "scripts/main.js"
}
],
"dependencies": [
{
"module_name": "@minecraft/server",
"version": "2.0.0"
"version": "1.17.0"
},
{
"module_name": "@minecraft/server-admin",
"version": "1.0.0-beta"
}
]
}

View File

@@ -1,4 +1,5 @@
import { world, system, ItemStack } from "@minecraft/server";
import { transferPlayer } from "@minecraft/server-admin";
const LOBBY_HOST = "10.0.0.247";
const LOBBY_PORT = 19132;
@@ -6,9 +7,12 @@ const COMPASS_ID = "minecraft:recovery_compass";
const PORTAL_PROP = "hub_portal_location";
const PORTAL_RADIUS = 3;
const TRANSFER_COOLDOWN = 5000; // 5 seconds
const SPAWN_PROTECTION = 10000; // 10 seconds — ignore portal detection after spawn
// Track recently transferred players
const recentTransfers = new Map();
// Track when players spawned (to prevent transfer loop on arrival)
const spawnTimes = new Map();
function doTransfer(player) {
const now = Date.now();
@@ -21,7 +25,7 @@ function doTransfer(player) {
world.sendMessage(`§a${player.name} is returning to the Hub...`);
try {
player.runCommand(`transfer ${player.name} ${LOBBY_HOST} ${LOBBY_PORT}`);
transferPlayer(player, { hostname: LOBBY_HOST, port: LOBBY_PORT });
} catch (e) {
player.sendMessage(`§cTransfer failed: ${e.message}`);
}
@@ -45,6 +49,8 @@ function giveCompassIfMissing(player) {
world.afterEvents.playerSpawn.subscribe((event) => {
const player = event.player;
// Mark spawn time for portal protection (prevents transfer loop on arrival)
spawnTimes.set(player.name, Date.now());
// Small delay to let inventory load
system.runTimeout(() => {
try {
@@ -82,9 +88,14 @@ system.runInterval(() => {
const players = world.getAllPlayers();
for (const player of players) {
// Skip if recently transferred (cooldown)
if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) {
continue;
}
// Skip if player just spawned (prevents loop when arriving from lobby)
if (spawnTimes.has(player.name) && now - spawnTimes.get(player.name) < SPAWN_PROTECTION) {
continue;
}
const pos = player.location;
const dx = Math.abs(pos.x - portal.x);
@@ -131,7 +142,7 @@ function handleChatCommand(player, msg) {
return false;
}
// Try beforeEvents.chatSend (pre-release in some BDS versions)
// Listen for chat commands — try beforeEvents first (beta API), fall back gracefully
try {
world.beforeEvents.chatSend.subscribe((event) => {
const msg = event.message.trim().toLowerCase();
@@ -141,11 +152,13 @@ try {
system.run(() => handleChatCommand(player, msg));
}
});
} catch {
// chatSend not available — fall back to polling player chat via scriptEvent
} catch (e) {
// beforeEvents.chatSend requires beta API — chat commands won't work via chat,
// but compass and portal transfers still function
world.sendMessage("§e[Hub] §fChat commands (!hub, !lobby) unavailable — use compass or portal instead.");
}
// Fallback: listen for scriptevent commands (players run: /scriptevent hub:cmd <command>)
// scriptEvent fallback — players can use: /scriptevent hub:cmd hub
system.afterEvents.scriptEventReceive.subscribe((event) => {
if (event.id !== "hub:cmd") return;
const player = event.sourceEntity;

View File

@@ -4,7 +4,7 @@
"name": "Lobby Portal Transfer",
"description": "Auto-transfers players when they step into portal areas",
"uuid": "a1b2c3d4-1111-2222-3333-abcdef123456",
"version": [1, 0, 1],
"version": [1, 0, 3],
"min_engine_version": [1, 21, 0]
},
"modules": [
@@ -12,14 +12,18 @@
"type": "script",
"language": "javascript",
"uuid": "a1b2c3d4-4444-5555-6666-abcdef789012",
"version": [1, 0, 1],
"version": [1, 0, 3],
"entry": "scripts/main.js"
}
],
"dependencies": [
{
"module_name": "@minecraft/server",
"version": "2.0.0"
"version": "1.17.0"
},
{
"module_name": "@minecraft/server-admin",
"version": "1.0.0-beta"
}
]
}

View File

@@ -1,4 +1,5 @@
import { world, system } from "@minecraft/server";
import { transferPlayer } from "@minecraft/server-admin";
// Portal definitions: name, center position, and direct transfer target
const portals = [
@@ -11,9 +12,16 @@ const PORTAL_RADIUS_X = 2.5;
const PORTAL_RADIUS_Z = 2.0;
const PORTAL_RADIUS_Y = 2.0;
const COOLDOWN_TICKS = 100; // 5 seconds cooldown
const SPAWN_PROTECTION_TICKS = 200; // 10 seconds — ignore portal detection after spawn
// Track cooldowns per player
const cooldowns = new Map();
// Track when players spawned (to prevent transfer loop on arrival)
const spawnTicks = new Map();
world.afterEvents.playerSpawn.subscribe((event) => {
spawnTicks.set(event.player.id, system.currentTick);
});
system.runInterval(() => {
for (const player of world.getAllPlayers()) {
@@ -24,6 +32,10 @@ system.runInterval(() => {
const lastTransfer = cooldowns.get(playerId) || 0;
if (system.currentTick - lastTransfer < COOLDOWN_TICKS) continue;
// Skip if player just spawned (prevents loop when arriving from child world)
const spawnedAt = spawnTicks.get(playerId) || 0;
if (system.currentTick - spawnedAt < SPAWN_PROTECTION_TICKS) continue;
for (const portal of portals) {
const dx = Math.abs(pos.x - portal.x);
const dy = Math.abs(pos.y - portal.y);
@@ -33,7 +45,7 @@ system.runInterval(() => {
cooldowns.set(playerId, system.currentTick);
player.sendMessage(`§6Transferring to ${portal.name}...`);
try {
player.runCommand(`transfer ${player.name} ${portal.host} ${portal.port}`);
transferPlayer(player, { hostname: portal.host, port: portal.port });
} catch (e) {
player.sendMessage(`§cTransfer failed: ${e.message}`);
}
@@ -43,9 +55,10 @@ system.runInterval(() => {
}
}, 10); // Check every half second
// Clean up cooldowns when players leave
// Clean up tracking when players leave
world.afterEvents.playerLeave.subscribe((event) => {
cooldowns.delete(event.playerId);
spawnTicks.delete(event.playerId);
});
system.run(() => {

108
scripts/enable-beta-apis.py Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
Enable Beta APIs experiment in a Bedrock level.dat file.
Bedrock level.dat format:
- 4 bytes: version (little-endian int32)
- 4 bytes: payload length (little-endian int32)
- rest: uncompressed little-endian NBT payload
This script patches the 'experiments' compound tag to add:
- gametest = 1 (byte)
- experiments_ever_used = 1 (byte)
- saved_with_toggled_experiments = 1 (byte)
Requires: amulet-nbt (pip install amulet-nbt)
"""
import struct
import sys
import shutil
import amulet_nbt
def patch_level_dat(path: str) -> None:
# Read the file
with open(path, "rb") as f:
header = f.read(8)
payload = f.read()
version, length = struct.unpack("<II", header)
print(f" Header: version={version}, payload_length={length}")
print(f" Actual payload size: {len(payload)}")
# Parse little-endian NBT (Bedrock format)
nbt = amulet_nbt.load(payload, little_endian=True)
root = nbt.compound
# Find experiments compound
if "experiments" not in root:
print(" ERROR: 'experiments' compound tag not found!")
sys.exit(1)
experiments = root["experiments"]
print(f" Current experiments: {dict((k, v) for k, v in experiments.items())}")
changed = False
# Set flags to 1
for key in ("experiments_ever_used", "saved_with_toggled_experiments"):
if key in experiments:
if experiments[key].py_int == 0:
experiments[key] = amulet_nbt.ByteTag(1)
changed = True
print(f" Set {key} = 1")
else:
experiments[key] = amulet_nbt.ByteTag(1)
changed = True
print(f" Added {key} = 1")
# Add gametest experiment
if "gametest" not in experiments:
experiments["gametest"] = amulet_nbt.ByteTag(1)
changed = True
print(" Added gametest = 1")
elif experiments["gametest"].py_int == 0:
experiments["gametest"] = amulet_nbt.ByteTag(1)
changed = True
print(" Set gametest = 1")
if not changed:
print(" Already patched — no changes needed.")
return
# Serialize back (uncompressed, little-endian for Bedrock)
new_payload = nbt.save_to(little_endian=True, compressed=False)
# Build new header with updated length
new_header = struct.pack("<II", version, len(new_payload))
# Backup original
backup_path = path + ".bak"
shutil.copy2(path, backup_path)
print(f" Backup saved to {backup_path}")
# Write patched file
with open(path, "wb") as f:
f.write(new_header)
f.write(new_payload)
print(f" Patched! New payload size: {len(new_payload)}")
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <level.dat> [level.dat ...]")
sys.exit(1)
for path in sys.argv[1:]:
print(f"\nPatching: {path}")
patch_level_dat(path)
print("\nDone!")
if __name__ == "__main__":
main()