/* eslint-disable guard-for-in */ // todo refactor into its own commons module import { EventEmitter } from 'events' import { generateSpiralMatrix, ViewRect } from 'flying-squid/dist/utils' import { Vec3 } from 'vec3' import { BotEvents } from 'mineflayer' import { proxy } from 'valtio' import TypedEmitter from 'typed-emitter' import { delayedIterator } from '../../playground/shared' import { chunkPos } from './simpleUtils' export type ChunkPosKey = string // like '16,16' type ChunkPos = { x: number, z: number } // like { x: 16, z: 16 } export type WorldDataEmitterEvents = { chunkPosUpdate: (data: { pos: Vec3 }) => void blockUpdate: (data: { pos: Vec3, stateId: number }) => void entity: (data: any) => void entityMoved: (data: any) => void playerEntity: (data: any) => void time: (data: number) => void renderDistance: (viewDistance: number) => void blockEntities: (data: Record | { blockEntities: Record }) => void markAsLoaded: (data: { x: number, z: number }) => void unloadChunk: (data: { x: number, z: number }) => void loadChunk: (data: { x: number, z: number, chunk: string, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void updateLight: (data: { pos: Vec3 }) => void onWorldSwitch: () => void end: () => void } export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter) { static readonly restorerName = 'WorldDataEmitterWorker' } export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter) { loadedChunks: Record readonly lastPos: Vec3 private eventListeners: Record = {} private readonly emitter: WorldDataEmitter debugChunksInfo: Record // blockUpdates: number }> = {} waitingSpiralChunksLoad = {} as Record void> addWaitTime = 1 /* config */ keepChunksDistance = 0 /* config */ isPlayground = false /* config */ allowPositionUpdate = true constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) { // eslint-disable-next-line constructor-super super() this.loadedChunks = {} this.lastPos = new Vec3(0, 0, 0).update(position) // todo this.emitter = this } setBlockStateId (position: Vec3, stateId: number) { const val = this.world.setBlockStateId(position, stateId) as Promise | void if (val) throw new Error('setBlockStateId returned promise (not supported)') // const chunkX = Math.floor(position.x / 16) // const chunkZ = Math.floor(position.z / 16) // if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) { // void this.loadChunk({ x: chunkX, z: chunkZ }) // return // } this.emit('blockUpdate', { pos: position, stateId }) } updateViewDistance (viewDistance: number) { this.viewDistance = viewDistance this.emitter.emit('renderDistance', viewDistance) } listenToBot (bot: typeof __type_bot) { const entitiesObjectData = new Map() bot._client.prependListener('spawn_entity', (data) => { if (data.objectData && data.entityId !== undefined) { entitiesObjectData.set(data.entityId, data.objectData) } }) const emitEntity = (e, name = 'entity') => { if (!e) return if (e === bot.entity) { if (name === 'entity') { this.emitter.emit('playerEntity', e) } return } if (!e.name) return // mineflayer received update for not spawned entity e.objectData = entitiesObjectData.get(e.id) this.emitter.emit(name as any, { ...e, pos: e.position, username: e.username, // set debugTree (obj) { // e.debugTree = obj // } }) } this.eventListeners = { // 'move': botPosition, entitySpawn (e: any) { if (e.name === 'item_frame' || e.name === 'glow_item_frame') { // Item frames use block positions in the protocol, not their center. Fix that. e.position.translate(0.5, 0.5, 0.5) } emitEntity(e) }, entityUpdate (e: any) { emitEntity(e) }, entityEquip (e: any) { emitEntity(e) }, entityMoved (e: any) { emitEntity(e, 'entityMoved') }, entityGone: (e: any) => { this.emitter.emit('entity', { id: e.id, delete: true }) }, chunkColumnLoad: (pos: Vec3) => { if (this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]) { this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`](true) delete this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] } else if (this.loadedChunks[`${pos.x},${pos.z}`]) { void this.loadChunk(pos, false, 'Received another chunkColumnLoad event while already loaded') } }, chunkColumnUnload: (pos: Vec3) => { this.unloadChunk(pos) }, blockUpdate: (oldBlock: any, newBlock: any) => { const stateId = newBlock.stateId ?? ((newBlock.type << 4) | newBlock.metadata) this.emitter.emit('blockUpdate', { pos: oldBlock.position, stateId }) }, time: () => { this.emitter.emit('time', bot.time.timeOfDay) }, end: () => { this.emitter.emit('end') }, // when dimension might change login: () => { void this.updatePosition(bot.entity.position, true) this.emitter.emit('playerEntity', bot.entity) }, respawn: () => { void this.updatePosition(bot.entity.position, true) this.emitter.emit('playerEntity', bot.entity) this.emitter.emit('onWorldSwitch') }, } satisfies Partial bot._client.on('update_light', ({ chunkX, chunkZ }) => { const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16) if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) { void this.loadChunk(chunkPos, true, 'update_light') } }) for (const [evt, listener] of Object.entries(this.eventListeners)) { bot.on(evt as any, listener) } for (const id in bot.entities) { const e = bot.entities[id] try { emitEntity(e) } catch (err) { // reportError?.(err) console.error('error processing entity', err) } } } emitterGotConnected () { this.emitter.emit('blockEntities', new Proxy({}, { get (_target, posKey, receiver) { if (typeof posKey !== 'string') return const [x, y, z] = posKey.split(',').map(Number) return bot.world.getBlock(new Vec3(x, y, z))?.entity }, })) } removeListenersFromBot (bot: import('mineflayer').Bot) { for (const [evt, listener] of Object.entries(this.eventListeners)) { bot.removeListener(evt as any, listener) } } async init (pos: Vec3) { this.updateViewDistance(this.viewDistance) this.emitter.emit('chunkPosUpdate', { pos }) if (bot?.time?.timeOfDay) { this.emitter.emit('time', bot.time.timeOfDay) } if (bot.entity) { this.emitter.emit('playerEntity', bot.entity) } this.emitterGotConnected() const [botX, botZ] = chunkPos(pos) const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16)) this.lastPos.update(pos) await this._loadChunks(positions) } async _loadChunks (positions: Vec3[], sliceSize = 5) { // stop loading previous chunks for (const pos of Object.keys(this.waitingSpiralChunksLoad)) { this.waitingSpiralChunksLoad[pos](false) delete this.waitingSpiralChunksLoad[pos] } const promises = [] as Array> let continueLoading = true await delayedIterator(positions, this.addWaitTime, async (pos) => { const promise = (async () => { if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return if (!this.world.getColumnAt(pos)) { continueLoading = await new Promise(resolve => { this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve }) } if (!continueLoading) return await this.loadChunk(pos) })() promises.push(promise) }) await Promise.all(promises) } readdDebug () { const clonedLoadedChunks = { ...this.loadedChunks } this.unloadAllChunks() console.time('readdDebug') for (const loadedChunk in clonedLoadedChunks) { const [x, z] = loadedChunk.split(',').map(Number) void this.loadChunk(new Vec3(x, 0, z)) } const interval = setInterval(() => { if (appViewer.rendererState.world.allChunksLoaded) { clearInterval(interval) console.timeEnd('readdDebug') } }, 100) } // debugGotChunkLatency = [] as number[] // lastTime = 0 async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') { const [botX, botZ] = chunkPos(this.lastPos) const dx = Math.abs(botX - Math.floor(pos.x / 16)) const dz = Math.abs(botZ - Math.floor(pos.z / 16)) if (dx <= this.viewDistance && dz <= this.viewDistance) { // eslint-disable-next-line @typescript-eslint/await-thenable -- todo allow to use async world provider but not sure if needed const column = await this.world.getColumnAt(pos['y'] ? pos as Vec3 : new Vec3(pos.x, 0, pos.z)) if (column) { // const latency = Math.floor(performance.now() - this.lastTime) // this.debugGotChunkLatency.push(latency) // this.lastTime = performance.now() // todo optimize toJson data, make it clear why it is used const chunk = column.toJson() // TODO: blockEntities const worldConfig = { minY: column['minY'] ?? 0, worldHeight: column['worldHeight'] ?? 256, } //@ts-expect-error this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate }) this.loadedChunks[`${pos.x},${pos.z}`] = true this.debugChunksInfo[`${pos.x},${pos.z}`] ??= { loads: [] } this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({ dataLength: chunk.length, reason, time: Date.now(), }) } else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z }) } } else { // console.debug('skipped loading chunk', dx, dz, '>', this.viewDistance) } } unloadAllChunks () { for (const coords of Object.keys(this.loadedChunks)) { const [x, z] = coords.split(',').map(Number) this.unloadChunk({ x, z }) } } unloadChunk (pos: ChunkPos) { this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z }) delete this.loadedChunks[`${pos.x},${pos.z}`] delete this.debugChunksInfo[`${pos.x},${pos.z}`] } async updatePosition (pos: Vec3, force = false) { if (!this.allowPositionUpdate) return const [lastX, lastZ] = chunkPos(this.lastPos) const [botX, botZ] = chunkPos(pos) if (lastX !== botX || lastZ !== botZ || force) { this.emitter.emit('chunkPosUpdate', { pos }) // unload chunks that are no longer in view const newViewToUnload = new ViewRect(botX, botZ, this.viewDistance + this.keepChunksDistance) const chunksToUnload: Vec3[] = [] for (const coords of Object.keys(this.loadedChunks)) { const x = parseInt(coords.split(',')[0], 10) const z = parseInt(coords.split(',')[1], 10) const p = new Vec3(x, 0, z) const [chunkX, chunkZ] = chunkPos(p) if (!newViewToUnload.contains(chunkX, chunkZ)) { chunksToUnload.push(p) } } console.log('unloading', chunksToUnload.length, 'total now', Object.keys(this.loadedChunks).length) for (const p of chunksToUnload) { this.unloadChunk(p) } // load new chunks const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => { const pos = new Vec3((botX + x) * 16, 0, (botZ + z) * 16) if (!this.loadedChunks[`${pos.x},${pos.z}`]) return pos return undefined! }).filter(a => !!a) this.lastPos.update(pos) void this._loadChunks(positions) } else { this.emitter.emit('chunkPosUpdate', { pos }) // todo-low this.lastPos.update(pos) } } }