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:
10
CLAUDE.md
10
CLAUDE.md
@@ -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`:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
108
scripts/enable-beta-apis.py
Normal 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user