UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

904 lines (903 loc) 41.4 kB
"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;