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.
|
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.
|
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
|
## Connection
|
||||||
|
|
||||||
The mc-ai-bridge runs at `10.0.0.247`:
|
The mc-ai-bridge runs at `10.0.0.247`:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"name": "Hub Return Transfer",
|
"name": "Hub Return Transfer",
|
||||||
"description": "Transfers players back to lobby when they step on the return portal",
|
"description": "Transfers players back to lobby when they step on the return portal",
|
||||||
"uuid": "b2c3d4e5-1111-2222-3333-fedcba654321",
|
"uuid": "b2c3d4e5-1111-2222-3333-fedcba654321",
|
||||||
"version": [1, 0, 1],
|
"version": [1, 0, 3],
|
||||||
"min_engine_version": [1, 21, 0]
|
"min_engine_version": [1, 21, 0]
|
||||||
},
|
},
|
||||||
"modules": [
|
"modules": [
|
||||||
@@ -12,14 +12,18 @@
|
|||||||
"type": "script",
|
"type": "script",
|
||||||
"language": "javascript",
|
"language": "javascript",
|
||||||
"uuid": "b2c3d4e5-4444-5555-6666-fedcba987654",
|
"uuid": "b2c3d4e5-4444-5555-6666-fedcba987654",
|
||||||
"version": [1, 0, 1],
|
"version": [1, 0, 3],
|
||||||
"entry": "scripts/main.js"
|
"entry": "scripts/main.js"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{
|
{
|
||||||
"module_name": "@minecraft/server",
|
"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 { world, system, ItemStack } from "@minecraft/server";
|
||||||
|
import { transferPlayer } from "@minecraft/server-admin";
|
||||||
|
|
||||||
const LOBBY_HOST = "10.0.0.247";
|
const LOBBY_HOST = "10.0.0.247";
|
||||||
const LOBBY_PORT = 19132;
|
const LOBBY_PORT = 19132;
|
||||||
@@ -6,9 +7,12 @@ const COMPASS_ID = "minecraft:recovery_compass";
|
|||||||
const PORTAL_PROP = "hub_portal_location";
|
const PORTAL_PROP = "hub_portal_location";
|
||||||
const PORTAL_RADIUS = 3;
|
const PORTAL_RADIUS = 3;
|
||||||
const TRANSFER_COOLDOWN = 5000; // 5 seconds
|
const TRANSFER_COOLDOWN = 5000; // 5 seconds
|
||||||
|
const SPAWN_PROTECTION = 10000; // 10 seconds — ignore portal detection after spawn
|
||||||
|
|
||||||
// Track recently transferred players
|
// Track recently transferred players
|
||||||
const recentTransfers = new Map();
|
const recentTransfers = new Map();
|
||||||
|
// Track when players spawned (to prevent transfer loop on arrival)
|
||||||
|
const spawnTimes = new Map();
|
||||||
|
|
||||||
function doTransfer(player) {
|
function doTransfer(player) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -21,7 +25,7 @@ function doTransfer(player) {
|
|||||||
world.sendMessage(`§a${player.name} is returning to the Hub...`);
|
world.sendMessage(`§a${player.name} is returning to the Hub...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
player.runCommand(`transfer ${player.name} ${LOBBY_HOST} ${LOBBY_PORT}`);
|
transferPlayer(player, { hostname: LOBBY_HOST, port: LOBBY_PORT });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
player.sendMessage(`§cTransfer failed: ${e.message}`);
|
player.sendMessage(`§cTransfer failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,8 @@ function giveCompassIfMissing(player) {
|
|||||||
|
|
||||||
world.afterEvents.playerSpawn.subscribe((event) => {
|
world.afterEvents.playerSpawn.subscribe((event) => {
|
||||||
const player = event.player;
|
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
|
// Small delay to let inventory load
|
||||||
system.runTimeout(() => {
|
system.runTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -82,9 +88,14 @@ system.runInterval(() => {
|
|||||||
const players = world.getAllPlayers();
|
const players = world.getAllPlayers();
|
||||||
|
|
||||||
for (const player of players) {
|
for (const player of players) {
|
||||||
|
// Skip if recently transferred (cooldown)
|
||||||
if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) {
|
if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) {
|
||||||
continue;
|
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 pos = player.location;
|
||||||
const dx = Math.abs(pos.x - portal.x);
|
const dx = Math.abs(pos.x - portal.x);
|
||||||
@@ -131,7 +142,7 @@ function handleChatCommand(player, msg) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try beforeEvents.chatSend (pre-release in some BDS versions)
|
// Listen for chat commands — try beforeEvents first (beta API), fall back gracefully
|
||||||
try {
|
try {
|
||||||
world.beforeEvents.chatSend.subscribe((event) => {
|
world.beforeEvents.chatSend.subscribe((event) => {
|
||||||
const msg = event.message.trim().toLowerCase();
|
const msg = event.message.trim().toLowerCase();
|
||||||
@@ -141,11 +152,13 @@ try {
|
|||||||
system.run(() => handleChatCommand(player, msg));
|
system.run(() => handleChatCommand(player, msg));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
// chatSend not available — fall back to polling player chat via scriptEvent
|
// 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) => {
|
system.afterEvents.scriptEventReceive.subscribe((event) => {
|
||||||
if (event.id !== "hub:cmd") return;
|
if (event.id !== "hub:cmd") return;
|
||||||
const player = event.sourceEntity;
|
const player = event.sourceEntity;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"name": "Lobby Portal Transfer",
|
"name": "Lobby Portal Transfer",
|
||||||
"description": "Auto-transfers players when they step into portal areas",
|
"description": "Auto-transfers players when they step into portal areas",
|
||||||
"uuid": "a1b2c3d4-1111-2222-3333-abcdef123456",
|
"uuid": "a1b2c3d4-1111-2222-3333-abcdef123456",
|
||||||
"version": [1, 0, 1],
|
"version": [1, 0, 3],
|
||||||
"min_engine_version": [1, 21, 0]
|
"min_engine_version": [1, 21, 0]
|
||||||
},
|
},
|
||||||
"modules": [
|
"modules": [
|
||||||
@@ -12,14 +12,18 @@
|
|||||||
"type": "script",
|
"type": "script",
|
||||||
"language": "javascript",
|
"language": "javascript",
|
||||||
"uuid": "a1b2c3d4-4444-5555-6666-abcdef789012",
|
"uuid": "a1b2c3d4-4444-5555-6666-abcdef789012",
|
||||||
"version": [1, 0, 1],
|
"version": [1, 0, 3],
|
||||||
"entry": "scripts/main.js"
|
"entry": "scripts/main.js"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{
|
{
|
||||||
"module_name": "@minecraft/server",
|
"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 { world, system } from "@minecraft/server";
|
||||||
|
import { transferPlayer } from "@minecraft/server-admin";
|
||||||
|
|
||||||
// Portal definitions: name, center position, and direct transfer target
|
// Portal definitions: name, center position, and direct transfer target
|
||||||
const portals = [
|
const portals = [
|
||||||
@@ -11,9 +12,16 @@ const PORTAL_RADIUS_X = 2.5;
|
|||||||
const PORTAL_RADIUS_Z = 2.0;
|
const PORTAL_RADIUS_Z = 2.0;
|
||||||
const PORTAL_RADIUS_Y = 2.0;
|
const PORTAL_RADIUS_Y = 2.0;
|
||||||
const COOLDOWN_TICKS = 100; // 5 seconds cooldown
|
const COOLDOWN_TICKS = 100; // 5 seconds cooldown
|
||||||
|
const SPAWN_PROTECTION_TICKS = 200; // 10 seconds — ignore portal detection after spawn
|
||||||
|
|
||||||
// Track cooldowns per player
|
// Track cooldowns per player
|
||||||
const cooldowns = new Map();
|
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(() => {
|
system.runInterval(() => {
|
||||||
for (const player of world.getAllPlayers()) {
|
for (const player of world.getAllPlayers()) {
|
||||||
@@ -24,6 +32,10 @@ system.runInterval(() => {
|
|||||||
const lastTransfer = cooldowns.get(playerId) || 0;
|
const lastTransfer = cooldowns.get(playerId) || 0;
|
||||||
if (system.currentTick - lastTransfer < COOLDOWN_TICKS) continue;
|
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) {
|
for (const portal of portals) {
|
||||||
const dx = Math.abs(pos.x - portal.x);
|
const dx = Math.abs(pos.x - portal.x);
|
||||||
const dy = Math.abs(pos.y - portal.y);
|
const dy = Math.abs(pos.y - portal.y);
|
||||||
@@ -33,7 +45,7 @@ system.runInterval(() => {
|
|||||||
cooldowns.set(playerId, system.currentTick);
|
cooldowns.set(playerId, system.currentTick);
|
||||||
player.sendMessage(`§6Transferring to ${portal.name}...`);
|
player.sendMessage(`§6Transferring to ${portal.name}...`);
|
||||||
try {
|
try {
|
||||||
player.runCommand(`transfer ${player.name} ${portal.host} ${portal.port}`);
|
transferPlayer(player, { hostname: portal.host, port: portal.port });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
player.sendMessage(`§cTransfer failed: ${e.message}`);
|
player.sendMessage(`§cTransfer failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -43,9 +55,10 @@ system.runInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 10); // Check every half second
|
}, 10); // Check every half second
|
||||||
|
|
||||||
// Clean up cooldowns when players leave
|
// Clean up tracking when players leave
|
||||||
world.afterEvents.playerLeave.subscribe((event) => {
|
world.afterEvents.playerLeave.subscribe((event) => {
|
||||||
cooldowns.delete(event.playerId);
|
cooldowns.delete(event.playerId);
|
||||||
|
spawnTicks.delete(event.playerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
system.run(() => {
|
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