pages235/src/index.ts
Vitaly b2e36840b9
feat: brand new default skybox with fog, better daycycle and colors (#425)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-05 05:02:54 +03:00

1085 lines
38 KiB
TypeScript

/* eslint-disable import/order */
import './importsWorkaround'
import './styles.css'
import './testCrasher'
import './globals'
import './devtools'
import './entities'
import customChannels from './customChannels'
import './globalDomListeners'
import './mineflayer/maps'
import './mineflayer/cameraShake'
import './shims/patchShims'
import './mineflayer/java-tester/index'
import './external'
import './appConfig'
import './mineflayer/timers'
import './mineflayer/plugins'
import { getServerInfo } from './mineflayer/mc-protocol'
import { onGameLoad } from './inventoryWindows'
import initCollisionShapes from './getCollisionInteractionShapes'
import protocolMicrosoftAuth from 'minecraft-protocol/src/client/microsoftAuth'
import microsoftAuthflow from './microsoftAuthflow'
import { Duplex } from 'stream'
import './scaleInterface'
import { options } from './optionsStorage'
import './reactUi'
import { lockUrl, onBotCreate } from './controls'
import './dragndrop'
import { possiblyCleanHandle } from './browserfs'
import downloadAndOpenFile, { isInterestedInDownload } from './downloadAndOpenFile'
import fs from 'fs'
import net, { Socket } from 'net'
import mineflayer from 'mineflayer'
import debug from 'debug'
import { defaultsDeep } from 'lodash-es'
import initializePacketsReplay from './packetsReplay/packetsReplayLegacy'
import {
activeModalStack,
activeModalStacks,
hideModal,
insertActiveModalStack,
isGameActive,
miscUiState,
showModal,
gameAdditionalState,
} from './globalState'
import { parseServerAddress } from './parseServerAddress'
import { setLoadingScreenStatus } from './appStatus'
import { isCypress } from './standaloneUtils'
import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer'
import defaultServerOptions from './defaultLocalServerOptions'
import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack'
import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer'
import CustomChannelClient from './customClient'
import { registerServiceWorker } from './serviceWorker'
import { appStatusState, lastConnectOptions, quickDevReconnect } from './react/AppStatusProvider'
import { fsState } from './loadSave'
import { watchFov } from './rendererUtils'
import { loadInMemorySave } from './react/SingleplayerProvider'
import { possiblyHandleStateVariable } from './googledrive'
import flyingSquidEvents from './flyingSquidEvents'
import { showNotification } from './react/NotificationProvider'
import { saveToBrowserMemory } from './react/PauseScreen'
import './devReload'
import './water'
import { ConnectOptions, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData, loadMinecraftData } from './connect'
import { ref, subscribe } from 'valtio'
import { signInMessageState } from './react/SignInMessageProvider'
import { findServerPassword, updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage'
import { mainMenuState } from './react/MainMenuRenderApp'
import './mobileShim'
import { parseFormattedMessagePacket } from './botUtils'
import { appStartup } from './clientMods'
import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector'
import { getWebsocketStream } from './mineflayer/websocket-core'
import { appQueryParams, appQueryParamsArray } from './appParams'
import { playerState } from './mineflayer/playerState'
import { states } from 'minecraft-protocol'
import { initMotionTracking } from './react/uiMotion'
import { UserError } from './mineflayer/userError'
import { startLocalReplayServer } from './packetsReplay/replayPackets'
import { createFullScreenProgressReporter, createWrappedProgressReporter, ProgressReporter } from './core/progressReporter'
import { appViewer } from './appViewer'
import './appViewerLoad'
import { registerOpenBenchmarkListener } from './benchmark'
import { tryHandleBuiltinCommand } from './builtinCommands'
import { loadingTimerState } from './react/LoadingTimer'
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
import { getCurrentProxy, getCurrentUsername } from './react/ServersList'
window.debug = debug
window.beforeRenderFrame = []
// ACTUAL CODE
void registerServiceWorker().then(() => {
mainMenuState.serviceWorkerLoaded = true
})
watchFov()
initCollisionShapes()
initializePacketsReplay()
onAppLoad()
customChannels()
if (appQueryParams.testCrashApp === '2') throw new Error('test')
function hideCurrentScreens () {
activeModalStacks['main-menu'] = [...activeModalStack]
insertActiveModalStack('', [])
}
const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}, connectOptions?: Partial<ConnectOptions>) => {
const serverSettingsQsRaw = appQueryParamsArray.serverSetting ?? []
const serverSettingsQs = serverSettingsQsRaw.map(x => x.split(':')).reduce<Record<string, string>>((acc, [key, value]) => {
acc[key] = JSON.parse(value)
return acc
}, {})
void connect({
singleplayer: true,
username: options.localUsername,
serverOverrides,
serverOverridesFlat: {
...flattenedServerOverrides,
...serverSettingsQs
},
...connectOptions
})
}
function listenGlobalEvents () {
window.addEventListener('connect', e => {
const options = (e as CustomEvent).detail
void connect(options)
})
window.addEventListener('singleplayer', (e) => {
const { detail } = (e as CustomEvent)
const { connectOptions, ...rest } = detail
loadSingleplayer(rest, {}, connectOptions)
})
}
export async function connect (connectOptions: ConnectOptions) {
if (miscUiState.gameLoaded) return
if (sessionStorage.delayLoadUntilFocus) {
await new Promise(resolve => {
if (document.hasFocus()) {
resolve(undefined)
} else {
window.addEventListener('focus', resolve)
}
})
}
if (sessionStorage.delayLoadUntilClick) {
await new Promise(resolve => {
window.addEventListener('click', resolve)
})
}
appStatusState.showReconnect = false
loadingTimerState.loading = true
loadingTimerState.start = Date.now()
miscUiState.hasErrors = false
lastConnectOptions.value = connectOptions
const { singleplayer } = connectOptions
const p2pMultiplayer = !!connectOptions.peerId
miscUiState.singleplayer = singleplayer
miscUiState.flyingSquid = singleplayer || p2pMultiplayer
// Track server connection in history
if (!singleplayer && !p2pMultiplayer && connectOptions.server && connectOptions.saveServerToHistory !== false) {
const parsedServer = parseServerAddress(connectOptions.server)
updateServerConnectionHistory(parsedServer.host, connectOptions.botVersion)
}
const { renderDistance: renderDistanceSingleplayer, multiplayerRenderDistance } = options
const parsedServer = parseServerAddress(connectOptions.server)
const server = { host: parsedServer.host, port: parsedServer.port }
if (connectOptions.proxy?.startsWith(':')) {
connectOptions.proxy = `${location.protocol}//${location.hostname}${connectOptions.proxy}`
}
if (connectOptions.proxy && location.port !== '80' && location.port !== '443' && !/:\d+$/.test(connectOptions.proxy)) {
const https = connectOptions.proxy.startsWith('https://') || location.protocol === 'https:'
connectOptions.proxy = `${connectOptions.proxy}:${https ? 443 : 80}`
}
const parsedProxy = parseServerAddress(connectOptions.proxy, false)
const proxy = { host: parsedProxy.host, port: parsedProxy.port }
let { username } = connectOptions
if (connectOptions.server) {
console.log(`connecting to ${server.host}:${server.port ?? 25_565}`)
}
console.log('using player username', username)
hideCurrentScreens()
const progress = createFullScreenProgressReporter()
const loggingInMsg = connectOptions.server ? 'Connecting to server' : 'Logging in'
progress.beginStage('connect', loggingInMsg)
let ended = false
let bot!: typeof __type_bot
let hadConnected = false
const destroyAll = (wasKicked = false) => {
if (ended) return
loadingTimerState.loading = false
const { alwaysReconnect } = appQueryParams
if ((!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) || (alwaysReconnect)) {
if (alwaysReconnect === 'quick' || alwaysReconnect === 'fast') {
quickDevReconnect()
} else {
location.reload()
}
}
errorAbortController.abort()
ended = true
progress.end()
// dont reset viewer so we can still do debugging
localServer = window.localServer = window.server = undefined
gameAdditionalState.viewerConnection = false
if (bot) {
bot.end()
// ensure mineflayer plugins receive this event for cleanup
bot.emit('end', '')
bot.removeAllListeners()
bot._client.removeAllListeners()
bot._client = {
//@ts-expect-error
write (packetName) {
console.warn('Tried to write packet', packetName, 'after bot was destroyed')
}
}
//@ts-expect-error
window.bot = bot = undefined
}
cleanFs()
}
const cleanFs = () => {
if (singleplayer && !fsState.inMemorySave) {
possiblyCleanHandle(() => {
// todo: this is not enough, we need to wait for all async operations to finish
})
}
}
let lastPacket = undefined as string | undefined
const onPossibleErrorDisconnect = () => {
if (lastPacket && bot?._client && bot._client.state !== states.PLAY) {
appStatusState.descriptionHint = `Last Server Packet: ${lastPacket}`
}
}
const handleError = (err) => {
console.error(err)
if (err === 'ResizeObserver loop completed with undelivered notifications.') {
return
}
if (isCypress()) throw err
miscUiState.hasErrors = true
if (miscUiState.gameLoaded) return
// close all modals
for (const modal of activeModalStack) {
hideModal(modal)
}
setLoadingScreenStatus(`Error encountered. ${err}`, true)
appStatusState.showReconnect = true
onPossibleErrorDisconnect()
destroyAll()
}
// todo(hard): remove it!
const errorAbortController = new AbortController()
window.addEventListener('unhandledrejection', (e) => {
if (e.reason.name === 'ServerPluginLoadFailure') {
if (confirm(`Failed to load server plugin ${e.reason.pluginName} (invoking ${e.reason.pluginMethod}). Continue?`)) {
return
}
}
if (e.reason?.stack?.includes('chrome-extension://')) {
// ignore issues caused by chrome extension
return
}
handleError(e.reason)
}, {
signal: errorAbortController.signal
})
window.addEventListener('error', (e) => {
handleError(e.message)
}, {
signal: errorAbortController.signal
})
let clientDataStream: Duplex | undefined
if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) {
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` }, artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined })
}
const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance
let updateDataAfterJoin = () => { }
let localServer
let localReplaySession: ReturnType<typeof startLocalReplayServer> | undefined
let lastKnownKickReason = undefined as string | undefined
try {
const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions)
Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {})
await progress.executeWithMessage('Downloading Minecraft data', 'download-mcdata', async () => {
loadingTimerState.networkOnlyStart = Date.now()
let downloadingAssets = [] as string[]
const reportAssetDownload = (asset: string, isDone: boolean) => {
if (isDone) {
downloadingAssets = downloadingAssets.filter(a => a !== asset)
} else {
downloadingAssets.push(asset)
}
progress.setSubStage('download-mcdata', `(${downloadingAssets.join(', ')})`)
}
await Promise.all([
downloadAllMinecraftData(reportAssetDownload),
downloadOtherGameData(reportAssetDownload)
])
loadingTimerState.networkOnlyStart = 0
})
let dataDownloaded = false
const downloadMcData = async (version: string) => {
if (dataDownloaded) return
dataDownloaded = true
appViewer.resourcesManager.currentConfig = { version, texturesVersion: options.useVersionsTextures || undefined }
await progress.executeWithMessage(
'Processing downloaded Minecraft data',
async () => {
await loadMinecraftData(version)
await appViewer.resourcesManager.loadSourceData(version)
}
)
await progress.executeWithMessage(
'Applying user-installed resource pack',
async () => {
try {
await resourcepackReload(true)
} catch (err) {
console.error(err)
const doContinue = confirm('Failed to apply texture pack. See errors in the console. Continue?')
if (!doContinue) {
throw err
}
}
}
)
await progress.executeWithMessage(
'Preparing textures',
async () => {
await appViewer.resourcesManager.updateAssetsData({})
}
)
}
let finalVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined)
if (connectOptions.worldStateFileContents) {
try {
localReplaySession = startLocalReplayServer(connectOptions.worldStateFileContents)
} catch (err) {
console.error(err)
throw new UserError(`Failed to start local replay server: ${err}`)
}
finalVersion = localReplaySession.version
}
if (singleplayer) {
// SINGLEPLAYER EXPLAINER:
// Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer)
// Note 2: custom Server class is used which simplifies communication & Client creation on it's side
// local server started
// mineflayer.createBot (see source def)
// bot._client = bot._client ?? mc.createClient(options) <-- mc-protocol package
// tcpDns() skipped since we define connect option
// in setProtocol: we emit 'connect' here below so in that file we send set_protocol and login_start (onLogin handler)
// Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler)
// flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer
const serverPlugins = new URLSearchParams(location.search).getAll('serverPlugin')
if (serverPlugins.length > 0 && !serverOptions.worldFolder) {
console.log('Placing server plugins', serverPlugins)
serverOptions.worldFolder ??= '/temp'
await loadPluginsIntoWorld('/temp', serverPlugins)
console.log('Server plugins placed')
}
localServer = window.localServer = window.server = startLocalServer(serverOptions)
connectOptions?.connectEvents?.serverCreated?.()
// todo need just to call quit if started
// loadingScreen.maybeRecoverable = false
// init world, todo: do it for any async plugins
if (!localServer.pluginsReady) {
await progress.executeWithMessage(
'Starting local server',
async () => {
await new Promise(resolve => {
localServer.once('pluginsReady', resolve)
})
}
)
}
localServer.on('newPlayer', (player) => {
player.on('loadingStatus', (newStatus) => {
progress.setMessage(newStatus)
})
})
flyingSquidEvents()
}
if (connectOptions.authenticatedAccount) username = 'you'
let initialLoadingText: string
if (singleplayer) {
initialLoadingText = 'Local server is still starting'
} else if (p2pMultiplayer) {
initialLoadingText = 'Connecting to peer'
} else if (connectOptions.server) {
if (!finalVersion) {
const versionAutoSelect = getVersionAutoSelect()
const wrapped = createWrappedProgressReporter(progress, `Fetching server version. Preffered: ${versionAutoSelect}`)
loadingTimerState.networkOnlyStart = Date.now()
const autoVersionSelect = await getServerInfo(server.host, server.port ? Number(server.port) : undefined, versionAutoSelect)
wrapped.end()
finalVersion = autoVersionSelect.version
}
initialLoadingText = `Connecting to server ${server.host}:${server.port ?? 25_565} with version ${finalVersion}`
} else if (connectOptions.viewerWsConnect) {
initialLoadingText = `Connecting to Mineflayer WebSocket server ${connectOptions.viewerWsConnect}`
} else if (connectOptions.worldStateFileContents) {
initialLoadingText = `Loading local replay server`
} else {
initialLoadingText = 'We have no idea what to do'
}
progress.setMessage(initialLoadingText)
if (parsedServer.isWebSocket) {
loadingTimerState.networkOnlyStart = Date.now()
clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream
}
let newTokensCacheResult = null as any
const cachedTokens = typeof connectOptions.authenticatedAccount === 'object' ? connectOptions.authenticatedAccount.cachedTokens : {}
let authData: Awaited<ReturnType<typeof microsoftAuthflow>> | undefined
if (connectOptions.authenticatedAccount) {
authData = await microsoftAuthflow({
tokenCaches: cachedTokens,
proxyBaseUrl: connectOptions.proxy,
setProgressText (text) {
progress.setMessage(text)
},
setCacheResult (result) {
newTokensCacheResult = result
},
connectingServer: server.host
})
}
if (p2pMultiplayer) {
clientDataStream = await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions)
}
if (connectOptions.viewerWsConnect) {
const { version, time, requiresPass } = await getViewerVersionData(connectOptions.viewerWsConnect)
let password
if (requiresPass) {
password = prompt('Enter password')
if (!password) {
throw new UserError('Password is required')
}
}
console.log('Latency:', Date.now() - time, 'ms')
// const version = '1.21.1'
finalVersion = version
await downloadMcData(version)
setLoadingScreenStatus(`Connecting to WebSocket server ${connectOptions.viewerWsConnect}`)
clientDataStream = (await getWsProtocolStream(connectOptions.viewerWsConnect)).clientDuplex
if (password) {
clientDataStream.write(password)
}
gameAdditionalState.viewerConnection = true
}
if (finalVersion) {
// ensure data is downloaded
loadingTimerState.networkOnlyStart ??= Date.now()
await downloadMcData(finalVersion)
}
const brand = clientDataStream ? 'minecraft-web-client' : undefined
bot = mineflayer.createBot({
host: server.host,
port: server.port ? +server.port : undefined,
brand,
version: finalVersion || false,
...clientDataStream ? {
stream: clientDataStream as any,
} : {},
...singleplayer || p2pMultiplayer || localReplaySession ? {
keepAlive: false,
} : {},
...singleplayer ? {
version: serverOptions.version,
connect () { },
Client: CustomChannelClient as any,
} : {},
...localReplaySession ? {
connect () { },
Client: CustomChannelClient as any,
} : {},
onMsaCode (data) {
signInMessageState.code = data.user_code
signInMessageState.link = data.verification_uri
signInMessageState.expiresOn = Date.now() + data.expires_in * 1000
},
sessionServer: authData?.sessionEndpoint?.toString(),
auth: connectOptions.authenticatedAccount ? async (client, options) => {
authData!.setOnMsaCodeCallback(options.onMsaCode)
authData?.setConnectingVersion(client.version)
//@ts-expect-error
client.authflow = authData!.authFlow
try {
signInMessageState.abortController = ref(new AbortController())
await Promise.race([
protocolMicrosoftAuth.authenticate(client, options),
new Promise((_r, reject) => {
signInMessageState.abortController.signal.addEventListener('abort', () => {
reject(new UserError('Aborted by user'))
})
})
])
if (signInMessageState.shouldSaveToken) {
updateAuthenticatedAccountData(accounts => {
const existingAccount = accounts.find(a => a.username === client.username)
if (existingAccount) {
existingAccount.cachedTokens = { ...existingAccount.cachedTokens, ...newTokensCacheResult }
} else {
accounts.push({
username: client.username,
cachedTokens: { ...cachedTokens, ...newTokensCacheResult }
})
}
return accounts
})
updateDataAfterJoin = () => {
updateLoadedServerData(s => ({ ...s, authenticatedAccountOverride: client.username }), connectOptions.serverIndex)
}
} else {
updateDataAfterJoin = () => {
updateLoadedServerData(s => ({ ...s, authenticatedAccountOverride: undefined }), connectOptions.serverIndex)
}
}
setLoadingScreenStatus('Authentication successful. Logging in to server')
} finally {
signInMessageState.code = ''
}
} : undefined,
username,
viewDistance: renderDistance,
checkTimeoutInterval: 240 * 1000,
// noPongTimeout: 240 * 1000,
closeTimeout: 240 * 1000,
respawn: options.autoRespawn,
maxCatchupTicks: 0,
'mapDownloader-saveToFile': false,
// "mapDownloader-saveInternal": false, // do not save into memory, todo must be implemeneted as we do really care of ram
}) as unknown as typeof __type_bot
window.bot = bot
if (connectOptions.viewerWsConnect) {
void onBotCreatedViewerHandler()
}
customEvents.emit('mineflayerBotCreated')
if (singleplayer || p2pMultiplayer || localReplaySession) {
if (singleplayer || p2pMultiplayer) {
// in case of p2pMultiplayer there is still flying-squid on the host side
const _supportFeature = bot.supportFeature
bot.supportFeature = ((feature) => {
if (unsupportedLocalServerFeatures.includes(feature)) {
return false
}
return _supportFeature(feature)
}) as typeof bot.supportFeature
}
bot.emit('inject_allowed')
bot._client.emit('connect')
} else if (clientDataStream) {
// bot.emit('inject_allowed')
bot._client.emit('connect')
} else {
const setupConnectHandlers = () => {
Socket.prototype['handleStringMessage'] = function (message: string) {
if (message.startsWith('proxy-message') || message.startsWith('proxy-command:')) { // for future
return false
}
if (message.startsWith('proxy-shutdown:')) {
lastKnownKickReason = message.slice('proxy-shutdown:'.length)
return false
}
return true
}
bot._client.socket.on('connect', () => {
console.log('Proxy WebSocket connection established')
//@ts-expect-error
bot._client.socket._ws.addEventListener('close', () => {
console.log('WebSocket connection closed')
setTimeout(() => {
if (bot) {
bot.emit('end', 'WebSocket connection closed with unknown reason')
}
}, 1000)
})
bot._client.socket.on('close', () => {
setTimeout(() => {
if (bot) {
bot.emit('end', 'WebSocket connection closed with unknown reason')
}
})
})
})
}
// socket setup actually can be delayed because of dns lookup
if (bot._client.socket) {
setupConnectHandlers()
} else {
const originalSetSocket = bot._client.setSocket.bind(bot._client)
bot._client.setSocket = (socket) => {
if (!bot) return
originalSetSocket(socket)
setupConnectHandlers()
}
}
}
} catch (err) {
handleError(err)
}
if (!bot) return
const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new UserError('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined
// bot.on('inject_allowed', () => {
// loadingScreen.maybeRecoverable = false
// })
bot.on('error', handleError)
bot.on('kicked', (kickReason) => {
console.log('You were kicked!', kickReason)
const { formatted: kickReasonFormatted, plain: kickReasonString } = parseFormattedMessagePacket(kickReason)
// close all modals
for (const modal of activeModalStack) {
hideModal(modal)
}
setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${kickReasonString}`, true, undefined, undefined, kickReasonFormatted)
appStatusState.showReconnect = true
destroyAll(true)
})
const packetBeforePlay = (_, __, ___, fullBuffer) => {
lastPacket = fullBuffer.toString()
}
bot._client.on('packet', packetBeforePlay as any)
const playStateSwitch = (newState) => {
if (newState === 'play') {
bot._client.removeListener('packet', packetBeforePlay)
}
}
bot._client.on('state', playStateSwitch)
bot.on('end', (endReason) => {
if (ended) return
console.log('disconnected for', endReason)
if (endReason === 'socketClosed') {
endReason = lastKnownKickReason ?? 'Connection with proxy server lost'
}
// close all modals
for (const modal of activeModalStack) {
hideModal(modal)
}
setLoadingScreenStatus(`You have been disconnected from the server. End reason:\n${endReason}`, true)
appStatusState.showReconnect = true
onPossibleErrorDisconnect()
destroyAll()
if (isCypress()) throw new Error(`disconnected: ${endReason}`)
})
onBotCreate()
bot.once('login', () => {
errorAbortController.abort()
loadingTimerState.networkOnlyStart = 0
progress.setMessage('Loading world')
})
let worldWasReady = false
const waitForChunksToLoad = async (progress?: ProgressReporter) => {
await new Promise<void>(resolve => {
if (worldWasReady) {
resolve()
return
}
const unsub = subscribe(appViewer.rendererState, () => {
if (appViewer.rendererState.world.allChunksLoaded && appViewer.nonReactiveState.world.chunksTotalNumber) {
worldWasReady = true
resolve()
unsub()
} else {
const perc = Math.round(appViewer.rendererState.world.chunksLoaded.size / appViewer.nonReactiveState.world.chunksTotalNumber * 100)
progress?.reportProgress('chunks', perc / 100)
}
})
})
}
const spawnEarlier = !singleplayer && !p2pMultiplayer
const displayWorld = async () => {
if (resourcePackState.isServerInstalling) {
await new Promise<void>(resolve => {
subscribe(resourcePackState, () => {
if (!resourcePackState.isServerInstalling) {
resolve()
}
})
})
await appViewer.resourcesManager.promiseAssetsReady
}
if (appStatusState.isError) return
if (!appViewer.resourcesManager.currentResources?.itemsRenderer) {
await appViewer.resourcesManager.updateAssetsData({})
}
const loadWorldStart = Date.now()
console.log('try to focus window')
window.focus?.()
void waitForChunksToLoad().then(() => {
window.worldLoadTime = (Date.now() - loadWorldStart) / 1000
console.log('All chunks done and ready! Time from renderer connect to ready', (Date.now() - loadWorldStart) / 1000, 's')
document.dispatchEvent(new Event('cypress-world-ready'))
})
try {
if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout)
playerState.reactive.onlineMode = !!connectOptions.authenticatedAccount
progress.setMessage('Placing blocks (starting viewer)')
if (!connectOptions.worldStateFileContents || connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) {
localStorage.lastConnectOptions = JSON.stringify(connectOptions)
if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !location.search.slice(1).length) {
lockUrl()
}
} else {
localStorage.removeItem('lastConnectOptions')
}
connectOptions.onSuccessfulPlay?.()
updateDataAfterJoin()
const password = findServerPassword()
if (password) {
setTimeout(() => {
bot.chat(`/login ${password}`)
}, 500)
}
console.log('bot spawned - starting viewer')
await appViewer.startWorld(bot.world, renderDistance)
appViewer.worldView!.listenToBot(bot)
if (appViewer.backend) {
void appViewer.worldView!.init(bot.entity.position)
}
initMotionTracking()
// Bot position callback
const botPosition = () => {
appViewer.lastCamUpdate = Date.now()
// this might cause lag, but not sure
appViewer.backend?.updateCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
void appViewer.worldView?.updatePosition(bot.entity.position)
}
bot.on('move', botPosition)
botPosition()
progress.setMessage('Setting callbacks')
onGameLoad()
if (appStatusState.isError) return
const waitForChunks = async () => {
if (appQueryParams.sp === '1') return //todo
const waitForChunks = options.waitForChunksRender === 'sp-only' ? !!singleplayer : options.waitForChunksRender
if (!appViewer.backend || appViewer.rendererState.world.allChunksLoaded || !waitForChunks) {
return
}
await progress.executeWithMessage(
'Loading chunks',
'chunks',
async () => {
await waitForChunksToLoad(progress)
}
)
}
await waitForChunks()
setTimeout(() => {
if (appQueryParams.suggest_save) {
showNotification('Suggestion', 'Save the world to keep your progress!', false, undefined, async () => {
const savePath = await saveToBrowserMemory()
if (!savePath) return
const saveName = savePath.split('/').pop()
bot.end()
// todo hot reload
location.search = `loadSave=${saveName}`
})
}
}, 600)
miscUiState.gameLoaded = true
miscUiState.loadedServerIndex = connectOptions.serverIndex ?? ''
customEvents.emit('gameLoaded')
// Test iOS Safari crash by creating memory pressure
if (appQueryParams.testIosCrash) {
setTimeout(() => {
console.log('Starting iOS crash test with memory pressure...')
// eslint-disable-next-line sonarjs/no-unused-collection
const arrays: number[][] = []
try {
// Create large arrays until we run out of memory
// eslint-disable-next-line no-constant-condition
while (true) {
const arr = Array.from({ length: 1024 * 1024 }).fill(0).map((_, i) => i)
arrays.push(arr)
}
} catch (e) {
console.error('Memory allocation failed:', e)
}
}, 1000)
}
progress.end()
setLoadingScreenStatus(undefined)
} catch (err) {
handleError(err)
}
hadConnected = true
}
// don't use spawn event, player can be dead
bot.once(spawnEarlier ? 'forcedMove' : 'health', displayWorld)
if (singleplayer && connectOptions.serverOverrides.worldFolder) {
fsState.saveLoaded = true
}
if (!connectOptions.ignoreQs || process.env.NODE_ENV === 'development') {
// todo cleanup
customEvents.on('gameLoaded', () => {
const commands = appQueryParamsArray.command ?? []
for (let command of commands) {
if (!command.startsWith('/')) command = `/${command}`
const builtinHandled = tryHandleBuiltinCommand(command)
if (!builtinHandled) {
bot.chat(command)
}
}
})
}
}
listenGlobalEvents()
// #region fire click event on touch as we disable default behaviors
let activeTouch: { touch: Touch, elem: HTMLElement, start: number } | undefined
document.body.addEventListener('touchend', (e) => {
if (!isGameActive(true)) return
if (activeTouch?.touch.identifier !== e.changedTouches[0].identifier) return
if (Date.now() - activeTouch.start > 500) {
activeTouch.elem.dispatchEvent(new Event('longtouch', { bubbles: true }))
} else {
activeTouch.elem.click()
}
activeTouch = undefined
})
document.body.addEventListener('touchstart', (e) => {
const targetElement = (e.target as HTMLElement).closest('#ui-root')
if (!isGameActive(true) || !targetElement) return
// we always prevent default behavior to disable magnifier on ios, but by doing so we also disable click events
e.preventDefault()
let firstClickable // todo remove composedPath and this workaround when lit-element is fully dropped
const path = e.composedPath() as Array<{ click?: () => void }>
for (const elem of path) {
if (elem.click) {
firstClickable = elem
break
}
}
if (!firstClickable) return
activeTouch = {
touch: e.touches[0],
elem: firstClickable,
start: Date.now(),
}
}, { passive: false })
// #endregion
// immediate game enter actions: reconnect or URL QS
const maybeEnterGame = () => {
const waitForConfigFsLoad = (fn: () => void) => {
let unsubscribe: () => void | undefined
const checkDone = () => {
if (miscUiState.fsReady && miscUiState.appConfig) {
fn()
unsubscribe?.()
return true
}
return false
}
if (!checkDone()) {
const text = miscUiState.appConfig ? 'Loading' : 'Loading config'
setLoadingScreenStatus(text)
unsubscribe = subscribe(miscUiState, checkDone)
}
}
const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined
if (reconnectOptions) {
sessionStorage.removeItem('reconnectOptions')
if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) {
return waitForConfigFsLoad(async () => {
void connect(reconnectOptions.value)
})
}
}
if (appQueryParams.reconnect && localStorage.lastConnectOptions && process.env.NODE_ENV === 'development') {
const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {})
return waitForConfigFsLoad(async () => {
void connect({
botVersion: appQueryParams.version ?? undefined,
...lastConnect,
ip: appQueryParams.ip || undefined
})
})
}
if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') {
return waitForConfigFsLoad(async () => {
loadSingleplayer({}, {
worldFolder: undefined,
...appQueryParams.version ? { version: appQueryParams.version } : {}
})
})
}
if (appQueryParams.loadSave) {
const enterSave = async () => {
const savePath = `/data/worlds/${appQueryParams.loadSave}`
try {
await fs.promises.stat(savePath)
await loadInMemorySave(savePath)
} catch (err) {
alert(`Save ${savePath} not found`)
}
}
return waitForConfigFsLoad(enterSave)
}
if (appQueryParams.ip || appQueryParams.proxy) {
const openServerAction = () => {
if (appQueryParams.autoConnect && miscUiState.appConfig?.allowAutoConnect) {
void connect({
server: appQueryParams.ip,
proxy: getCurrentProxy(),
botVersion: appQueryParams.version ?? undefined,
username: getCurrentUsername()!,
})
return
}
setLoadingScreenStatus(undefined)
if (appQueryParams.onlyConnect || process.env.ALWAYS_MINIMAL_SERVER_UI === 'true') {
showModal({ reactType: 'only-connect-server' })
} else {
showModal({ reactType: 'editServer' })
}
}
// showModal({ reactType: 'empty' })
return waitForConfigFsLoad(openServerAction)
}
if (appQueryParams.connectPeer) {
// try to connect to peer
const peerId = appQueryParams.connectPeer
const peerOptions = {} as ConnectPeerOptions
if (appQueryParams.server) {
peerOptions.server = appQueryParams.server
}
const version = appQueryParams.peerVersion
let username: string | null = options.guestUsername
if (options.askGuestName) username = prompt('Enter your username to connect to peer', username)
if (!username) return
options.guestUsername = username
void connect({
username,
botVersion: version || undefined,
peerId,
peerOptions
})
return
}
if (appQueryParams.viewerConnect) {
void connect({
username: `viewer-${Math.random().toString(36).slice(2, 10)}`,
viewerWsConnect: appQueryParams.viewerConnect,
})
return
}
if (appQueryParams.modal) {
const modals = appQueryParams.modal.split(',')
for (const modal of modals) {
showModal({ reactType: modal })
}
return
}
if (appQueryParams.serversList && !miscUiState.appConfig?.appParams?.serversList) {
// open UI only if it's in URL
showModal({ reactType: 'serversList' })
}
if (isInterestedInDownload()) {
void downloadAndOpenFile()
}
void possiblyHandleStateVariable()
}
try {
maybeEnterGame()
} catch (err) {
console.error(err)
alert(`Something went wrong: ${err}`)
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const initialLoader = document.querySelector('.initial-loader') as HTMLElement | null
if (initialLoader) {
initialLoader.style.opacity = '0'
initialLoader.style.pointerEvents = 'none'
}
window.pageLoaded = true
appViewer.waitBackendLoadPromises.push(appStartup())
registerOpenBenchmarkListener()