284 lines
9.3 KiB
JavaScript
284 lines
9.3 KiB
JavaScript
const { ClientStatus, Connection } = require('./connection')
|
|
const { createDeserializer, createSerializer } = require('./transforms/serializer')
|
|
const { serialize, isDebug } = require('./datatypes/util')
|
|
const debug = require('debug')('minecraft-protocol')
|
|
const Options = require('./options')
|
|
const auth = require('./client/auth')
|
|
const initRaknet = require('./rak')
|
|
const { KeyExchange } = require('./handshake/keyExchange')
|
|
const Login = require('./handshake/login')
|
|
const LoginVerify = require('./handshake/loginVerify')
|
|
|
|
const debugging = false
|
|
|
|
class Client extends Connection {
|
|
// The RakNet connection
|
|
connection
|
|
|
|
/** @param {{ version: number, host: string, port: number }} options */
|
|
constructor (options) {
|
|
super()
|
|
this.options = { ...Options.defaultOptions, ...options }
|
|
|
|
this.startGameData = {}
|
|
this.clientRuntimeId = null
|
|
// Start off without compression on 1.19.30, zlib on below
|
|
this.compressionAlgorithm = this.versionGreaterThanOrEqualTo('1.19.30') ? 'none' : 'deflate'
|
|
this.compressionThreshold = 512
|
|
this.compressionLevel = this.options.compressionLevel
|
|
this.batchHeader = 0xfe
|
|
|
|
if (isDebug) {
|
|
this.inLog = (...args) => debug('C ->', ...args)
|
|
this.outLog = (...args) => debug('C <-', ...args)
|
|
}
|
|
this.conLog = this.options.conLog === undefined ? console.log : this.options.conLog
|
|
|
|
if (!options.delayedInit) {
|
|
this.init()
|
|
}
|
|
}
|
|
|
|
init () {
|
|
this.validateOptions()
|
|
this.serializer = createSerializer(this.options.version)
|
|
this.deserializer = createDeserializer(this.options.version)
|
|
this._loadFeatures()
|
|
|
|
KeyExchange(this, null, this.options)
|
|
Login(this, null, this.options)
|
|
LoginVerify(this, null, this.options)
|
|
|
|
const { RakClient } = initRaknet(this.options.raknetBackend)
|
|
const host = this.options.host
|
|
const port = this.options.port
|
|
this.connection = new RakClient({ useWorkers: this.options.useRaknetWorkers, host, port }, this)
|
|
|
|
this.emit('connect_allowed')
|
|
}
|
|
|
|
_loadFeatures () {
|
|
try {
|
|
const mcData = require('minecraft-data')('bedrock_' + this.options.version)
|
|
this.features = {
|
|
compressorInHeader: mcData.supportFeature('compressorInPacketHeader'),
|
|
itemRegistryPacket: mcData.supportFeature('itemRegistryPacket'),
|
|
newLoginIdentityFields: mcData.supportFeature('newLoginIdentityFields')
|
|
}
|
|
} catch (e) {
|
|
throw new Error(`Unsupported version: '${this.options.version}', no data available`)
|
|
}
|
|
}
|
|
|
|
connect () {
|
|
if (!this.connection) throw new Error('Connect not currently allowed') // must wait for `connect_allowed`, or use `createClient`
|
|
this.on('session', this._connect)
|
|
|
|
if (this.options.offline) {
|
|
debug('offline mode, not authenticating', this.options)
|
|
auth.createOfflineSession(this, this.options)
|
|
} else {
|
|
auth.authenticate(this, this.options)
|
|
}
|
|
|
|
this.startQueue()
|
|
}
|
|
|
|
validateOptions () {
|
|
if (!this.options.host || this.options.port == null) throw Error('Invalid host/port')
|
|
Options.validateOptions(this.options)
|
|
}
|
|
|
|
get entityId () {
|
|
return this.startGameData.runtime_entity_id
|
|
}
|
|
|
|
onEncapsulated = (encapsulated, inetAddr) => {
|
|
const buffer = Buffer.from(encapsulated.buffer)
|
|
process.nextTick(() => this.handle(buffer))
|
|
}
|
|
|
|
async ping () {
|
|
try {
|
|
return await this.connection.ping(this.options.connectTimeout)
|
|
} catch (e) {
|
|
this.conLog?.(`Unable to connect to [${this.options.host}]/${this.options.port}. Is the server running?`)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
_connect = async (sessionData) => {
|
|
debug('[client] connecting to', this.options.host, this.options.port, sessionData, this.connection)
|
|
this.connection.onConnected = () => {
|
|
this.status = ClientStatus.Connecting
|
|
if (this.versionGreaterThanOrEqualTo('1.19.30')) {
|
|
this.queue('request_network_settings', { client_protocol: this.options.protocolVersion })
|
|
} else {
|
|
this.sendLogin()
|
|
}
|
|
}
|
|
this.connection.onCloseConnection = (reason) => {
|
|
if (this.status === ClientStatus.Disconnected) this.conLog?.(`Server closed connection: ${reason}`)
|
|
this.close()
|
|
}
|
|
this.connection.onEncapsulated = this.onEncapsulated
|
|
this.connection.connect()
|
|
|
|
this.connectTimeout = setTimeout(() => {
|
|
if (this.status === ClientStatus.Disconnected) {
|
|
this.connection.close()
|
|
this.emit('error', Error('Connect timed out'))
|
|
}
|
|
}, this.options.connectTimeout || 9000)
|
|
}
|
|
|
|
updateCompressorSettings (packet) {
|
|
this.compressionAlgorithm = packet.compression_algorithm || 'deflate'
|
|
this.compressionThreshold = packet.compression_threshold
|
|
this.compressionReady = true
|
|
}
|
|
|
|
sendLogin () {
|
|
this.status = ClientStatus.Authenticating
|
|
this.createClientChain(null, this.options.offline)
|
|
|
|
const chain = [
|
|
this.clientIdentityChain, // JWT we generated for auth
|
|
...this.accessToken // Mojang + Xbox JWT from auth
|
|
]
|
|
|
|
let encodedChain
|
|
if (this.features.newLoginIdentityFields) { // 1.21.90+
|
|
encodedChain = JSON.stringify({
|
|
Certificate: JSON.stringify({ chain }),
|
|
// 0 = normal, 1 = ss, 2 = offline
|
|
AuthenticationType: this.options.offline ? 2 : 0,
|
|
Token: ''
|
|
})
|
|
} else {
|
|
encodedChain = JSON.stringify({ chain })
|
|
}
|
|
debug('Auth chain', encodedChain)
|
|
|
|
this.write('login', {
|
|
protocol_version: this.options.protocolVersion,
|
|
tokens: {
|
|
identity: encodedChain,
|
|
client: this.clientUserChain
|
|
}
|
|
})
|
|
this.emit('loggingIn')
|
|
}
|
|
|
|
onDisconnectRequest (packet) {
|
|
this.conLog?.(`Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`)
|
|
this.emit('kick', packet)
|
|
this.close()
|
|
}
|
|
|
|
onPlayStatus (statusPacket) {
|
|
if (this.status === ClientStatus.Initializing && this.options.autoInitPlayer === true) {
|
|
if (statusPacket.status === 'player_spawn') {
|
|
this.status = ClientStatus.Initialized
|
|
if (!this.entityId) {
|
|
// We need to wait for start_game in the rare event we get a player_spawn before start_game race condition
|
|
this.on('start_game', () => this.write('set_local_player_as_initialized', { runtime_entity_id: this.entityId }))
|
|
} else {
|
|
this.write('set_local_player_as_initialized', { runtime_entity_id: this.entityId })
|
|
}
|
|
this.emit('spawn')
|
|
}
|
|
}
|
|
}
|
|
|
|
disconnect (reason = 'Client leaving', hide = false) {
|
|
if (this.status === ClientStatus.Disconnected) return
|
|
this.write('disconnect', {
|
|
hide_disconnect_screen: hide,
|
|
message: reason,
|
|
filtered_message: ''
|
|
})
|
|
this.close(reason)
|
|
}
|
|
|
|
close () {
|
|
if (this.status !== ClientStatus.Disconnected) {
|
|
this.emit('close') // Emit close once
|
|
debug('Client closed!')
|
|
}
|
|
clearInterval(this.loop)
|
|
clearTimeout(this.connectTimeout)
|
|
this.q = []
|
|
this.q2 = []
|
|
this.connection?.close()
|
|
this.removeAllListeners()
|
|
this.status = ClientStatus.Disconnected
|
|
}
|
|
|
|
readPacket (packet) {
|
|
try {
|
|
var des = this.deserializer.parsePacketBuffer(packet) // eslint-disable-line
|
|
} catch (e) {
|
|
// Dump information about the packet only if user is not handling error event.
|
|
if (this.listenerCount('error') === 0) this.deserializer.dumpFailedBuffer(packet)
|
|
this.emit('error', e)
|
|
return
|
|
}
|
|
const pakData = { name: des.data.name, params: des.data.params }
|
|
this.inLog?.('-> C', pakData.name, this.options.logging ? serialize(pakData.params) : '')
|
|
this.emit('packet', des)
|
|
|
|
if (debugging) {
|
|
// Packet verifying (decode + re-encode + match test)
|
|
if (pakData.name) {
|
|
this.deserializer.verify(packet, this.serializer)
|
|
}
|
|
}
|
|
|
|
// Abstract some boilerplate before sending to listeners
|
|
switch (des.data.name) {
|
|
case 'server_to_client_handshake':
|
|
this.emit('client.server_handshake', des.data.params)
|
|
break
|
|
case 'network_settings':
|
|
this.updateCompressorSettings(des.data.params)
|
|
if (this.status === ClientStatus.Connecting) {
|
|
this.sendLogin()
|
|
}
|
|
break
|
|
case 'disconnect': // Client kicked
|
|
this.emit(des.data.name, des.data.params) // Emit before we kill all listeners.
|
|
this.onDisconnectRequest(des.data.params)
|
|
break
|
|
case 'start_game':
|
|
this.startGameData = pakData.params
|
|
// fallsthrough
|
|
case 'item_registry': // 1.21.60+ send itemstates in item_registry packet
|
|
pakData.params.itemstates?.forEach(state => {
|
|
if (state.name === 'minecraft:shield') {
|
|
this.serializer.proto.setVariable('ShieldItemID', state.runtime_id)
|
|
this.deserializer.proto.setVariable('ShieldItemID', state.runtime_id)
|
|
}
|
|
})
|
|
break
|
|
case 'play_status':
|
|
if (this.status === ClientStatus.Authenticating) {
|
|
this.inLog?.('Server wants to skip encryption')
|
|
this.emit('join')
|
|
this.status = ClientStatus.Initializing
|
|
}
|
|
this.onPlayStatus(pakData.params)
|
|
break
|
|
default:
|
|
if (this.status !== ClientStatus.Initializing && this.status !== ClientStatus.Initialized) {
|
|
this.inLog?.(`Can't accept ${des.data.name}, client not yet authenticated : ${this.status}`)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Emit packet
|
|
this.emit(des.data.name, des.data.params)
|
|
}
|
|
}
|
|
|
|
module.exports = { Client }
|