@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
904 lines (903 loc) • 41.4 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const CreatorTools_1 = require("./CreatorTools");
const CreatorToolsAuthentication_1 = require("./CreatorToolsAuthentication");
const IMinecraft_1 = require("./IMinecraft");
const axios_1 = __importDefault(require("axios"));
const ste_events_1 = require("ste-events");
const Project_1 = __importDefault(require("./Project"));
const ZipStorage_1 = __importDefault(require("../storage/ZipStorage"));
const StorageUtilities_1 = __importDefault(require("../storage/StorageUtilities"));
const Log_1 = __importDefault(require("../core/Log"));
const GameStateManager_1 = __importDefault(require("../minecraft/GameStateManager"));
const HttpStorage_1 = __importDefault(require("../storage/HttpStorage"));
const MinecraftUtilities_1 = __importDefault(require("../minecraft/MinecraftUtilities"));
const ProjectExporter_1 = __importDefault(require("./ProjectExporter"));
const ProjectAutogeneration_1 = __importDefault(require("./ProjectAutogeneration"));
const Status_1 = require("./Status");
class RemoteMinecraft {
_creatorTools;
state;
_project;
_lastFullPush;
_onStateChanged = new ste_events_1.EventDispatcher();
_gameStateManager;
_nextPollInterval = 500;
_pollIntervalCount = 0;
// WebSocket for status notifications
_webSocket = null;
_useWebSocket = true; // Try WebSocket first, fall back to polling
_wsReconnectTimer;
errorStatus;
errorMessage;
/**
* Configuration metadata for the slot, received once at connection time.
* Contains flags like debuggerEnabled and debuggerStreamingEnabled that
* inform the UI about what features are available.
*/
slotConfig;
worldFolder;
projectFolder;
worldContentStorage;
worldProject;
/**
* Map of connected player names to their info (position, xuid, etc.)
* Updated when playerJoined/playerLeft/playerMoved events are received via WebSocket.
*/
_connectedPlayers = new Map();
_onPlayersChanged = new ste_events_1.EventDispatcher();
messagesReceived = {};
_onWorldStorageReady = new ste_events_1.EventDispatcher();
_onProjectStorageReady = new ste_events_1.EventDispatcher();
_onMessage = new ste_events_1.EventDispatcher();
_onWorldContentChanged = new ste_events_1.EventDispatcher();
/**
* Event fired when the MCT server sends a shutdown notification.
* This indicates the entire server is shutting down (not just a BDS instance).
* Args: { reason: string, graceful: boolean }
*/
_onServerShutdown = new ste_events_1.EventDispatcher();
get onWorldFolderReady() {
return this._onWorldStorageReady.asEvent();
}
get onProjectFolderReady() {
return this._onProjectStorageReady.asEvent();
}
get onMessage() {
return this._onMessage.asEvent();
}
/**
* Event fired when world content files change on the server.
* This allows the WorldView component to refresh its data.
*/
get onWorldContentChanged() {
return this._onWorldContentChanged.asEvent();
}
/**
* Event fired when the connected players list changes (join/leave).
* Returns array of player names currently connected.
*/
get onPlayersChanged() {
return this._onPlayersChanged.asEvent();
}
/**
* Event fired when the MCT server is shutting down.
* Subscribers should show appropriate UI feedback (e.g., banner message)
* and understand the connection will be lost.
*/
get onServerShutdown() {
return this._onServerShutdown.asEvent();
}
/**
* Get array of currently connected player names.
*/
get connectedPlayerNames() {
return Array.from(this._connectedPlayers.keys());
}
/**
* Get detailed info for all connected players.
*/
get connectedPlayers() {
return Array.from(this._connectedPlayers.values());
}
get canDeployFiles() {
return true;
}
get activeProject() {
return this._project;
}
set activeProject(newProject) {
this._project = newProject;
}
get gameStateManager() {
return this._gameStateManager;
}
get onStateChanged() {
return this._onStateChanged.asEvent();
}
get onRefreshed() {
return this._onStateChanged.asEvent();
}
canPrepare() {
return true;
}
constructor(creatorTools) {
this._creatorTools = creatorTools;
this.state = CreatorTools_1.CreatorToolsMinecraftState.none;
this._gameStateManager = new GameStateManager_1.default(this._creatorTools);
this.doHeartbeat = this.doHeartbeat.bind(this);
this._handleWebSocketMessage = this._handleWebSocketMessage.bind(this);
}
async updateStatus() {
return this.state;
}
setState(newState) {
if (this.state === newState) {
return;
}
this.state = newState;
this._onStateChanged.dispatch(this, this.state);
}
processExternalMessage(command, data) {
switch (command.toLowerCase()) {
case "wsevent":
try {
const eventObj = JSON.parse(data);
this._gameStateManager.handleEvent(eventObj);
}
catch (e) {
Log_1.default.verbose("Failed to parse message: " + e);
}
break;
}
}
async prepare(force) { }
/**
* Initialize the worldContentStorage with an HttpStorage pointing to the server's
* /api/worldContent/{slot}/ endpoint. This provides access to the server's
* behavior_packs, resource_packs, and world folders.
*
* Also establishes a WebSocket connection for real-time file change notifications.
*/
async initWorldContentStorage() {
const baseUrl = this._creatorTools.fullRemoteServerUrl;
const slot = this._creatorTools.remoteServerPort ?? 0;
if (!baseUrl) {
Log_1.default.debug("Cannot initialize worldContentStorage: no remote server URL");
return;
}
// Create HttpStorage pointing to /api/worldContent/{slot}/
// Note: baseUrl already ends with slash from fullRemoteServerUrl
const worldContentUrl = `${baseUrl}api/worldContent/${slot}/`;
const storage = new HttpStorage_1.default(worldContentUrl);
// Set slot so UI components can make slot-specific API calls
storage.slot = slot;
// Set auth token so requests are authenticated
if (this._creatorTools.remoteServerAuthToken) {
storage.authToken = this._creatorTools.remoteServerAuthToken;
}
this.worldContentStorage = storage;
// Connect to the WebSocket notification server for real-time file updates
// This allows WorldView to receive updates when files change on the server
try {
// Derive WebSocket URL from the base URL
const wsBaseUrl = baseUrl.replace(/^http/, "ws");
const wsUrl = `${wsBaseUrl}ws/notifications`;
Log_1.default.message(`[RemoteMinecraft] Connecting HttpStorage WebSocket to ${wsUrl}`);
await storage.connect(wsUrl, this._creatorTools.remoteServerAuthToken);
// Subscribe to file change events for this slot
await storage.subscribe(["fileAdded", "fileChanged", "fileRemoved", "folderChanged"], slot);
Log_1.default.message(`[RemoteMinecraft] HttpStorage WebSocket connected and subscribed for slot ${slot}`);
}
catch (e) {
Log_1.default.debug("Failed to connect HttpStorage WebSocket (falling back to polling): " + e);
// File updates will still work but won't be real-time
}
// Also initialize the worldFolder for WorldView to use
await this.initWorldFolder();
}
/**
* Initialize the worldFolder from worldContentStorage for WorldDisplay rendering.
* The world folder is at /world/ within the worldContentStorage.
*/
async initWorldFolder() {
if (!this.worldContentStorage) {
return;
}
try {
// Get the world folder from worldContentStorage
// The server exposes /api/worldContent/{slot}/world/ which maps to the active world
this.worldFolder = this.worldContentStorage.rootFolder.ensureFolder("world");
await this.worldFolder.load();
// Dispatch the event so WorldView knows the folder is ready
this._onWorldStorageReady.dispatch(this, this.worldFolder);
}
catch (e) {
Log_1.default.debug("Error initializing worldFolder: " + e);
}
}
/**
* Connect to the WebSocket notification endpoint for real-time status updates.
* Falls back to polling if WebSocket connection fails.
*/
async connectWebSocket() {
const baseUrl = this._creatorTools.fullRemoteServerUrl;
const token = this._creatorTools.remoteServerAuthToken;
if (!baseUrl || !token) {
Log_1.default.debug("Cannot connect WebSocket: missing URL or token");
return false;
}
try {
// Derive WebSocket URL from baseUrl
const urlObj = new URL(baseUrl);
const wsProtocol = urlObj.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${urlObj.host}/ws/notifications?token=${encodeURIComponent(token)}`;
this._webSocket = new WebSocket(wsUrl);
return new Promise((resolve) => {
if (!this._webSocket) {
resolve(false);
return;
}
this._webSocket.onopen = () => {
Log_1.default.verbose("WebSocket connected for status updates");
// Subscribe to statusUpdate events for this slot
const slot = this._creatorTools.remoteServerPort ?? 0;
const subscribeMessage = {
header: {
version: 1,
requestId: Date.now().toString(),
messageType: "subscriptionRequest",
messagePurpose: "subscribe",
},
body: {
eventNames: [
"statusUpdate",
"playerJoined",
"playerLeft",
"serverStateChanged",
"playerMoved",
"gameEvent",
],
slot: slot,
},
};
this._webSocket?.send(JSON.stringify(subscribeMessage));
resolve(true);
};
this._webSocket.onclose = () => {
Log_1.default.verbose("WebSocket disconnected, falling back to polling");
this._webSocket = null;
// If we were using WebSocket, try to reconnect after a delay
if (this._useWebSocket && !this._wsReconnectTimer) {
this._wsReconnectTimer = setTimeout(() => {
this._wsReconnectTimer = undefined;
if (this._useWebSocket && this.state !== CreatorTools_1.CreatorToolsMinecraftState.disconnected) {
this.connectWebSocket().catch(() => {
// Fall back to polling if reconnection fails
this.startPolling();
});
}
}, 5000);
}
else {
// Fall back to polling
this.startPolling();
}
};
this._webSocket.onerror = () => {
Log_1.default.debug("WebSocket error, will fall back to polling");
resolve(false);
};
this._webSocket.onmessage = (event) => {
this._handleWebSocketMessage(event.data);
};
// Timeout if connection doesn't establish quickly
setTimeout(() => {
if (this._webSocket?.readyState !== WebSocket.OPEN) {
resolve(false);
}
}, 5000);
});
}
catch (e) {
Log_1.default.debug("Failed to connect WebSocket: " + e);
return false;
}
}
/**
* Handle incoming WebSocket messages for status updates.
*/
_handleWebSocketMessage(data) {
try {
const notification = JSON.parse(data);
if (notification.body?.eventName === "statusUpdate") {
// Process the status update
const slot = notification.body.slot ?? 0;
const status = {
id: slot,
time: notification.body.timestamp,
status: notification.body.status,
recentMessages: notification.body.recentMessages?.map((msg) => ({
message: msg.message,
received: msg.received,
})),
};
this.processServerStatus(status);
}
else if (notification.body?.eventName === "playerJoined") {
// Track player connection
const joinBody = notification.body;
if (joinBody.playerName) {
this._connectedPlayers.set(joinBody.playerName, {
name: joinBody.playerName,
xuid: joinBody.xuid,
});
Log_1.default.message(`Player joined: ${joinBody.playerName}`);
this._onPlayersChanged.dispatch(this, this.connectedPlayerNames);
}
}
else if (notification.body?.eventName === "playerLeft") {
// Remove player from tracking
const leaveBody = notification.body;
if (leaveBody.playerName) {
this._connectedPlayers.delete(leaveBody.playerName);
Log_1.default.message(`Player left: ${leaveBody.playerName}`);
this._onPlayersChanged.dispatch(this, this.connectedPlayerNames);
}
}
else if (notification.body?.eventName === "playerMoved") {
// Handle player movement updates
const moveBody = notification.body;
if (moveBody.playerName && moveBody.position) {
// Update player position in our tracking map
const existing = this._connectedPlayers.get(moveBody.playerName);
this._connectedPlayers.set(moveBody.playerName, {
name: moveBody.playerName,
xuid: existing?.xuid,
position: moveBody.position,
dimension: moveBody.dimension,
});
}
if (moveBody.position) {
// Convert to a PlayerTravelled-style event for GameStateManager
const playerTravelledEvent = {
eventId: `move_${Date.now()}`,
header: {
eventName: "PlayerTravelled",
purpose: "event",
version: 1,
},
body: {
isUnderwater: false,
metersTravelled: 0,
newBiome: 0,
player: {
color: "",
dimension: 0,
id: 0,
name: moveBody.playerName ?? "unknown",
position: moveBody.position,
type: "player",
variant: 0,
yRot: 0,
},
travelMethod: 0,
},
};
this._gameStateManager.handleEvent(playerTravelledEvent);
}
}
else if (notification.body?.eventName === "gameEvent") {
// Handle generic game events from Minecraft
const gameEventBody = notification.body;
if (gameEventBody.data) {
this._gameStateManager.handleEvent(gameEventBody.data);
}
}
else if (notification.body?.eventName === "fileAdded" ||
notification.body?.eventName === "fileChanged" ||
notification.body?.eventName === "fileRemoved" ||
notification.body?.eventName === "folderChanged") {
// Handle storage change notifications for world content
const changeBody = notification.body;
Log_1.default.message(`[RemoteMinecraft] Storage change notification: ${changeBody.eventName} ${changeBody.category}${changeBody.path} (slot ${changeBody.slot})`);
// Dispatch the world content changed event
this._onWorldContentChanged.dispatch(this, {
eventName: changeBody.eventName,
category: changeBody.category ?? "",
path: changeBody.path ?? "",
slot: changeBody.slot ?? 0,
});
}
else if (notification.body?.eventName === "serverShutdown") {
// Handle server shutdown notification
const shutdownBody = notification.body;
const reason = shutdownBody.reason || "Server shutting down";
const graceful = shutdownBody.graceful !== false; // Default to true
// Use notifyStatusUpdate for more visible user notification
const shutdownMessage = `Server shutdown: ${reason}`;
Log_1.default.message(`[RemoteMinecraft] ${shutdownMessage}`);
this._creatorTools.notifyStatusUpdate(shutdownMessage, Status_1.StatusTopic.general);
// Disable WebSocket reconnection since the server is intentionally going away
this._useWebSocket = false;
// Dispatch the shutdown event for UI to handle
this._onServerShutdown.dispatch(this, { reason, graceful });
}
}
catch (e) {
Log_1.default.debug("Error handling WebSocket message: " + e);
}
}
/**
* Disconnect WebSocket and clean up.
*/
disconnectWebSocket() {
if (this._wsReconnectTimer) {
clearTimeout(this._wsReconnectTimer);
this._wsReconnectTimer = undefined;
}
if (this._webSocket) {
this._webSocket.close();
this._webSocket = null;
}
}
/**
* Start polling for status updates (fallback when WebSocket is unavailable).
*/
startPolling() {
if (this._nextPollInterval < 0) {
return; // Polling disabled
}
// @ts-ignore
window.setTimeout(this.doHeartbeat, this._nextPollInterval);
}
/**
* Lazily create a Project wrapper for the worldContentStorage.
* This allows treating the server's world content as a Project.
*/
async ensureWorldProject() {
if (this.worldProject) {
return this.worldProject;
}
if (!this.worldContentStorage) {
await this.initWorldContentStorage();
}
if (!this.worldContentStorage) {
return undefined;
}
// Create a Project that wraps the world content storage
this.worldProject = new Project_1.default(this._creatorTools, "World Content", null);
this.worldProject.setProjectFolder(this.worldContentStorage.rootFolder);
return this.worldProject;
}
async prepareAndStart(push) {
if (!this._creatorTools.remoteServerAuthToken) {
Log_1.default.debug("Remote server auth token is not set. Please connect to a remote server or select a different server mode.");
return {
type: IMinecraft_1.PrepareAndStartResultType.error,
errorMessage: "No remote server authentication token is available. Please connect to a remote server first, or switch to a different server mode (e.g., Host Minecraft Server).",
};
}
this.setState(CreatorTools_1.CreatorToolsMinecraftState.preparing);
if (!push.project) {
return {
type: IMinecraft_1.PrepareAndStartResultType.started,
};
}
await ProjectAutogeneration_1.default.updateProjectAutogeneration(push.project, false);
const zipStorage = new ZipStorage_1.default();
//await StorageUtilities.syncFolderTo(carto.deploymentStorage.rootFolder, zipStorage.rootFolder, true, true, false);
await ProjectExporter_1.default.deployProject(this._creatorTools, push.project, zipStorage.rootFolder);
await zipStorage.rootFolder.saveAll();
// consider doing a diff from the last push.
if (this._lastFullPush) {
const differenceSet = await StorageUtilities_1.default.getDifferences(this._lastFullPush.rootFolder, zipStorage.rootFolder, true, false);
this._lastFullPush = zipStorage;
// we don't have to do anything here. (may need a force flag here at some point)
if (differenceSet.fileDifferences.length === 0 && differenceSet.folderDifferences.length === 0) {
Log_1.default.message("No changes detected in this update. No push is being made.");
return {
type: IMinecraft_1.PrepareAndStartResultType.error,
};
}
if (!differenceSet.getHasDeletions()) {
const diffUpdate = new ZipStorage_1.default();
await differenceSet.copyFileUpdatesAndAdds(diffUpdate);
await diffUpdate.rootFolder.saveAll();
const diffBinary = await diffUpdate.generateBlobAsync();
this._creatorTools.notifyStatusUpdate("Uploading changed files to " + this._creatorTools.fullRemoteServerUrl);
const isReloadable = MinecraftUtilities_1.default.isReloadableSetOfChanges(differenceSet);
try {
await (0, axios_1.default)({
method: "patch",
url: this.getBaseApiUrl() + "upload/", //API url
headers: {
Authorization: "Bearer mctauth=" + this._creatorTools.remoteServerAuthToken,
"Content-Type": "application/zip",
"mcttools-reloadable": isReloadable,
},
data: diffBinary, // Buffer
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
}
catch (error) {
const eulaResult = this._handleUploadError(error);
if (eulaResult) {
return eulaResult;
}
throw error;
}
this._creatorTools.notifyStatusUpdate("Upload complete");
return {
type: IMinecraft_1.PrepareAndStartResultType.started,
};
}
}
this._creatorTools.notifyStatusUpdate("Files created in zip. Packaging");
const zipBinary = await zipStorage.generateBlobAsync();
this._lastFullPush = zipStorage;
this._creatorTools.notifyStatusUpdate("Uploading to " + this._creatorTools.fullRemoteServerUrl);
try {
await (0, axios_1.default)({
method: "post",
url: this.getBaseApiUrl() + "upload/", //API url
headers: {
Authorization: "Bearer mctauth=" + this._creatorTools.remoteServerAuthToken,
"Content-Type": "application/zip",
},
data: zipBinary, // Buffer
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
}
catch (error) {
const eulaResult = this._handleUploadError(error);
if (eulaResult) {
return eulaResult;
}
throw error;
}
this.setState(CreatorTools_1.CreatorToolsMinecraftState.prepared);
this._creatorTools.notifyStatusUpdate("Upload complete");
return {
type: IMinecraft_1.PrepareAndStartResultType.started,
};
}
/**
* Handle upload errors, specifically the EULA_REQUIRED 451 error.
* Returns an error result if EULA is required, otherwise returns undefined
* to let the caller re-throw the error.
*/
_handleUploadError(error) {
const axiosError = error;
if (axiosError.response?.status === 451) {
// HTTP 451 "Unavailable For Legal Reasons" - EULA not accepted
const responseData = axiosError.response.data;
if (responseData?.eulaRequired) {
// Set the flag so the UI shows the EulaAcceptancePanel
this._creatorTools.remoteServerEulaAccepted = false;
this._creatorTools.notifyStatusUpdate("EULA acceptance required. Please accept the Minecraft EULA to deploy content.");
this.setState(CreatorTools_1.CreatorToolsMinecraftState.initialized);
return {
type: IMinecraft_1.PrepareAndStartResultType.error,
errorMessage: responseData.message || "EULA acceptance required",
};
}
}
return undefined;
}
async runActionSet(actionSet) {
return undefined;
}
async runCommand(command) {
if (!this._creatorTools.remoteServerAuthToken) {
Log_1.default.throwUnexpectedUndefined("RC");
return;
}
this.resetInterval();
const result = await (0, axios_1.default)({
method: "post",
url: this.getBaseApiUrl() + "command/", //API url
headers: {
Authorization: "Bearer mctauth=" + this._creatorTools.remoteServerAuthToken,
},
data: command,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
return result.data;
}
getBaseApiUrl() {
if (!this._creatorTools.fullRemoteServerUrl) {
Log_1.default.throwUnexpectedUndefined("GBAU");
return "";
}
let port = this._creatorTools.remoteServerPort;
if (!port) {
port = 0;
}
// Note: fullRemoteServerUrl already ends with slash
return this._creatorTools.fullRemoteServerUrl + "api/" + port + "/";
}
async initSession(slot) { }
async syncWithDeployment() { }
async stop() {
const baseUrl = this._creatorTools.fullRemoteServerUrl;
const token = this._creatorTools.remoteServerAuthToken;
const slot = this._creatorTools.remoteServerPort ?? 0;
if (!baseUrl || !token) {
Log_1.default.debug("Cannot stop: not connected to remote server");
return;
}
try {
Log_1.default.message("Stopping remote server...");
this.setState(CreatorTools_1.CreatorToolsMinecraftState.stopping);
await (0, axios_1.default)({
method: "POST",
url: `${baseUrl}api/${slot}/stop`,
headers: {
Authorization: "Bearer " + token,
},
});
this.setState(CreatorTools_1.CreatorToolsMinecraftState.stopped);
Log_1.default.message("Remote server stopped");
}
catch (e) {
Log_1.default.error("Failed to stop remote server: " + (e.message || e));
this.errorMessage = "Failed to stop server: " + (e.message || e);
// Reset to stopped state so the UI doesn't get stuck on "(stopping...)"
// If the stop request failed (e.g., 404 because no server exists), the server
// is effectively stopped from the client's perspective.
this.setState(CreatorTools_1.CreatorToolsMinecraftState.stopped);
}
}
async restart() {
const baseUrl = this._creatorTools.fullRemoteServerUrl;
const token = this._creatorTools.remoteServerAuthToken;
const slot = this._creatorTools.remoteServerPort ?? 0;
if (!baseUrl || !token) {
Log_1.default.debug("Cannot restart: not connected to remote server");
return;
}
try {
Log_1.default.message("Restarting remote server...");
this.setState(CreatorTools_1.CreatorToolsMinecraftState.stopping);
await (0, axios_1.default)({
method: "POST",
url: `${baseUrl}api/${slot}/restart`,
headers: {
Authorization: "Bearer " + token,
},
});
// Server will report its new state via WebSocket
Log_1.default.message("Remote server restart initiated");
}
catch (e) {
Log_1.default.error("Failed to restart remote server: " + (e.message || e));
this.errorMessage = "Failed to restart server";
}
}
async processServerStatus(newStatus) {
let wasActive = false;
// Store slot config if provided and not already set (one-time at connection)
if (newStatus.slotConfig && !this.slotConfig) {
this.slotConfig = newStatus.slotConfig;
Log_1.default.verbose(`Received slot config: debuggerEnabled=${newStatus.slotConfig.debuggerEnabled}, debuggerStreamingEnabled=${newStatus.slotConfig.debuggerStreamingEnabled}`);
}
if (newStatus.status) {
switch (newStatus.status) {
case CreatorToolsAuthentication_1.DedicatedServerStatus.deploying:
if (this.state !== CreatorTools_1.CreatorToolsMinecraftState.preparing) {
this.setState(CreatorTools_1.CreatorToolsMinecraftState.preparing);
wasActive = true;
}
break;
case CreatorToolsAuthentication_1.DedicatedServerStatus.launching:
if (this.state !== CreatorTools_1.CreatorToolsMinecraftState.starting) {
this.setState(CreatorTools_1.CreatorToolsMinecraftState.starting);
wasActive = true;
}
break;
case CreatorToolsAuthentication_1.DedicatedServerStatus.starting:
if (this.state !== CreatorTools_1.CreatorToolsMinecraftState.starting) {
this.setState(CreatorTools_1.CreatorToolsMinecraftState.starting);
wasActive = true;
}
break;
case CreatorToolsAuthentication_1.DedicatedServerStatus.started:
if (this.state !== CreatorTools_1.CreatorToolsMinecraftState.started) {
this.setState(CreatorTools_1.CreatorToolsMinecraftState.started);
if (!this._creatorTools.successfullyConnectedToRemoteMinecraft) {
this._creatorTools.successfullyConnectedToRemoteMinecraft = true;
this._creatorTools.save();
}
// Initialize worldFolder when server has started so WorldView can render the map
if (!this.worldFolder) {
await this.initWorldContentStorage();
}
wasActive = true;
}
break;
case CreatorToolsAuthentication_1.DedicatedServerStatus.stopped:
if (this.state !== CreatorTools_1.CreatorToolsMinecraftState.stopped) {
this.setState(CreatorTools_1.CreatorToolsMinecraftState.stopped);
wasActive = true;
}
break;
}
}
if (newStatus.recentMessages) {
for (const recentMessage of newStatus.recentMessages) {
if (!this.messagesReceived[recentMessage.received]) {
this.messagesReceived[recentMessage.received] = recentMessage;
this._creatorTools.notifyStatusUpdate(recentMessage.message, Status_1.StatusTopic.minecraft);
this._onMessage.dispatch(this, recentMessage);
}
}
}
return wasActive;
}
resetInterval() {
this._nextPollInterval = 100;
this._pollIntervalCount = 0;
}
async doHeartbeat() {
let wasActive = false;
try {
const result = await (0, axios_1.default)({
method: "get",
url: this.getBaseApiUrl() + "status/", //API url
headers: {
Authorization: "Bearer mctauth=" + this._creatorTools.remoteServerAuthToken,
},
});
const obj = result.data;
if (obj) {
wasActive = await this.processServerStatus(obj);
}
}
catch (e) {
if (e && e.response && e.response?.status === 404) {
this.errorMessage = "Did not find an active server at " + this.getBaseApiUrl();
this.setState(CreatorTools_1.CreatorToolsMinecraftState.disconnected);
return;
}
this.errorMessage = "Disconnected from server.";
this.setState(CreatorTools_1.CreatorToolsMinecraftState.disconnected);
return;
}
if (this._nextPollInterval >= 0) {
if (wasActive) {
this._nextPollInterval = 100;
this._pollIntervalCount = 0;
}
else {
this._pollIntervalCount++;
// back off our polling if nothing interesting is happening
if (this._pollIntervalCount === 50 && this._nextPollInterval < 500) {
this._nextPollInterval = 500;
this._pollIntervalCount = 0;
}
else if (this._pollIntervalCount === 50 && this._nextPollInterval === 500) {
this._nextPollInterval = 5000;
this._pollIntervalCount = 0;
}
}
// @ts-ignore
window.setTimeout(this.doHeartbeat, this._nextPollInterval);
}
}
async initialize() {
const url = this._creatorTools.fullRemoteServerUrl;
this._nextPollInterval = -1;
if (this.state === CreatorTools_1.CreatorToolsMinecraftState.initialized ||
this.state === CreatorTools_1.CreatorToolsMinecraftState.preparing ||
this.state === CreatorTools_1.CreatorToolsMinecraftState.prepared ||
this.state === CreatorTools_1.CreatorToolsMinecraftState.starting ||
this.state === CreatorTools_1.CreatorToolsMinecraftState.started) {
return;
}
this.setState(CreatorTools_1.CreatorToolsMinecraftState.initialized);
if (!this._creatorTools || !url || !this._creatorTools.remoteServerPasscode) {
this.errorMessage = "Not fully configured.";
this.errorStatus = CreatorTools_1.CreatorToolsMinecraftErrorStatus.configuration;
return;
}
let authReq;
try {
authReq = await axios_1.default.post(url + "api/auth/", "passcode=" + this._creatorTools.remoteServerPasscode);
if (authReq === undefined) {
this.errorMessage = "Could not connect to server.";
this.errorStatus = CreatorTools_1.CreatorToolsMinecraftErrorStatus.loginFailed;
this.setState(CreatorTools_1.CreatorToolsMinecraftState.error);
return;
}
if (authReq.status !== 200) {
this.errorMessage = "Login failed.";
this.errorStatus = CreatorTools_1.CreatorToolsMinecraftErrorStatus.loginFailed;
this.setState(CreatorTools_1.CreatorToolsMinecraftState.error);
return;
}
let result;
if (typeof authReq.data === "string") {
result = JSON.parse(authReq.data);
}
else if (typeof authReq.data === "object") {
result = authReq.data;
}
if (result === undefined) {
this.errorMessage = "Unexpected server error.";
this.errorStatus = CreatorTools_1.CreatorToolsMinecraftErrorStatus.serverError;
this.setState(CreatorTools_1.CreatorToolsMinecraftState.error);
return;
}
this.setState(CreatorTools_1.CreatorToolsMinecraftState.initialized);
// Build the full auth token including authTag for GCM encryption validation
this._creatorTools.remoteServerAuthToken =
result.token + "|" + result.iv + (result.authTag ? "|" + result.authTag : "");
this._creatorTools.remoteServerAccessLevel = result.permissionLevel;
this._creatorTools.remoteServerEulaAccepted = result.eulaAccepted;
// Auto-select the first active slot from the server response
// This handles cases where the server is running on a non-default slot
let desiredSlot = this._creatorTools.remoteServerPort;
let foundActiveSlot = false;
// Check if the desired slot has an active server
if (desiredSlot !== undefined && result.serverStatus[desiredSlot]?.id >= 0) {
foundActiveSlot = true;
}
// If desired slot has no active server, find the first active slot
if (!foundActiveSlot) {
for (const slotStr in result.serverStatus) {
const slot = parseInt(slotStr, 10);
const status = result.serverStatus[slot];
if (status && status.id >= 0) {
Log_1.default.verbose("Auto-selecting active slot " + slot + " (was " + desiredSlot + ")");
desiredSlot = slot;
this._creatorTools.remoteServerPort = slot;
foundActiveSlot = true;
break;
}
}
}
// Initialize world content storage with HttpStorage pointing to the server's worldContent endpoint
await this.initWorldContentStorage();
if (foundActiveSlot && desiredSlot !== undefined && result.serverStatus[desiredSlot]) {
const status = result.serverStatus[desiredSlot];
await this.processServerStatus(status);
}
await this._creatorTools.save();
// Try WebSocket first for real-time status updates
// Falls back to polling if WebSocket connection fails
const wsConnected = await this.connectWebSocket();
if (wsConnected) {
Log_1.default.verbose("Using WebSocket for status updates");
this._useWebSocket = true;
this._nextPollInterval = -1; // Disable polling when using WebSocket
}
else {
Log_1.default.verbose("WebSocket not available, using polling for status updates");
this._useWebSocket = false;
this._nextPollInterval = 500;
this.startPolling();
}
}
catch (e) {
this.errorMessage = e.toString();
if (this.errorMessage && this.errorMessage.indexOf("401") >= 0) {
this.errorStatus = CreatorTools_1.CreatorToolsMinecraftErrorStatus.loginFailed;
this.setState(CreatorTools_1.CreatorToolsMinecraftState.error);
}
else {
this.errorStatus = CreatorTools_1.CreatorToolsMinecraftErrorStatus.serverUnavailable;
this.setState(CreatorTools_1.CreatorToolsMinecraftState.error);
}
}
}
}
exports.default = RemoteMinecraft;