From a02a1758b5b29ec022cc1c5874ab8606bb3bf8e3 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Thu, 21 May 2026 09:43:30 +0200 Subject: [PATCH] spectator port --- README.md | 2 + config.json | 6 + src/config.js | 10 ++ src/constants/spectatorPackets.js | 39 +++++ src/index.js | 5 +- src/proxy/SpectatorProxyServer.js | 105 +++++++++++++ src/replay/StateReplayer.js | 32 +++- src/session/BotIdleBehavior.js | 5 +- src/session/ServerConnection.js | 22 ++- src/session/SessionManager.js | 56 +++++++ src/spectator/SpectatorHub.js | 237 ++++++++++++++++++++++++++++++ 11 files changed, 511 insertions(+), 8 deletions(-) create mode 100644 src/constants/spectatorPackets.js create mode 100644 src/proxy/SpectatorProxyServer.js create mode 100644 src/spectator/SpectatorHub.js diff --git a/README.md b/README.md index f2f07fb..388913d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ Copy the template structure and configure your parameters in `config.json` in th * **`bot`**: Bot behavior settings. * `antiAfk`: When no client is connected, randomly turns, sneaks, and swings so the bot stays active. * `antiAfkMinInterval` / `antiAfkMaxInterval`: Milliseconds between idle actions (default 1500–6000). +* **`spectator`**: Watch-only proxy (default port **25568**). Multiple clients can connect; they receive spectator gamemode and a live view of the bot (bot mode / idle) or the controlling player (client mode). No movement or interaction is forwarded upstream. + * `enabled`, `port`, `onlineMode`, `maxClients` (default 20). * **`cache`**: Memory usage controls for caching the world. --- diff --git a/config.json b/config.json index c72580c..76a7761 100644 --- a/config.json +++ b/config.json @@ -13,6 +13,12 @@ "onlineMode": true, "maxClients": 1 }, + "spectator": { + "enabled": true, + "port": 25568, + "onlineMode": true, + "maxClients": 20 + }, "sniffer": { "port": 25567, "onlineMode": false, diff --git a/src/config.js b/src/config.js index d06ba91..c54e1c0 100644 --- a/src/config.js +++ b/src/config.js @@ -24,6 +24,16 @@ function loadConfig() { // Apply defaults config.proxy = Object.assign({ host: '0.0.0.0', port: 25566, onlineMode: false, maxClients: 1 }, config.proxy); + config.spectator = Object.assign( + { + enabled: true, + host: '0.0.0.0', + port: 25568, + onlineMode: false, + maxClients: 20, + }, + config.spectator, + ); config.bot = Object.assign( { antiAfk: true, diff --git a/src/constants/spectatorPackets.js b/src/constants/spectatorPackets.js new file mode 100644 index 0000000..ae94702 --- /dev/null +++ b/src/constants/spectatorPackets.js @@ -0,0 +1,39 @@ +/** Gamemode id for spectator (clientbound game_state_change). */ +const SPECTATOR_GAMEMODE = 3; + +/** ClientboundAnimatePacket (minecraft-data: animation). */ +const ANIMATION_SWING_MAIN_HAND = 0; +const ANIMATION_SWING_OFF_HAND = 3; + +/** C2S packets allowed on the spectator port (everything else is dropped). */ +const SPECTATOR_ALLOWED_C2S = new Set([ + 'chunk_batch_received', + 'teleport_confirm', + 'keep_alive', + 'message_acknowledgement', + 'ping_request', +]); + +/** Movement-ish C2S — trigger camera lock + position snap when received. */ +const SPECTATOR_MOVEMENT_C2S = new Set([ + 'position', + 'position_look', + 'look', + 'flying', + 'vehicle_move', + 'steer_vehicle', + 'steer_boat', + 'paddle_boat', + 'player_input', + 'entity_action', + 'abilities', + 'player_abilities', +]); + +module.exports = { + SPECTATOR_GAMEMODE, + SPECTATOR_ALLOWED_C2S, + SPECTATOR_MOVEMENT_C2S, + ANIMATION_SWING_MAIN_HAND, + ANIMATION_SWING_OFF_HAND, +}; diff --git a/src/index.js b/src/index.js index 7fc5f9f..ef54ec2 100644 --- a/src/index.js +++ b/src/index.js @@ -19,7 +19,10 @@ let config; try { config = loadConfig(); log.info(`Loaded config: server=${config.server.host}:${config.server.port} version=${config.server.version}`); - log.info(`Proxy will listen on port ${config.proxy.port}`); + log.info(`Play proxy port ${config.proxy.port}`); + if (config.spectator.enabled !== false) { + log.info(`Spectator proxy port ${config.spectator.port} (max ${config.spectator.maxClients})`); + } } catch (err) { log.error(`Failed to load config: ${err.message}`); process.exit(1); diff --git a/src/proxy/SpectatorProxyServer.js b/src/proxy/SpectatorProxyServer.js new file mode 100644 index 0000000..4cbb6a3 --- /dev/null +++ b/src/proxy/SpectatorProxyServer.js @@ -0,0 +1,105 @@ +const mc = require('minecraft-protocol'); +const { createLogger } = require('../utils/logger'); +const { wrapClientEnd } = require('../utils/clientDisconnect'); +const { disableInboundChatValidation } = require('../utils/chatRelay'); + +const log = createLogger('SpectatorProxy'); + +/** + * Multi-client proxy port — spectators only (read-only watch stream). + */ +class SpectatorProxyServer { + /** + * @param {object} config + * @param {import('../spectator/SpectatorHub')} hub + * @param {import('../state/WorldStateCache').WorldStateCache} worldState + * @param {() => { ok: boolean, reason?: string }} canAcceptClient + */ + constructor(config, hub, worldState, canAcceptClient) { + this.config = config; + this.hub = hub; + this.worldState = worldState; + this.canAcceptClient = canAcceptClient; + this.server = null; + } + + start() { + const spec = this.config.spectator; + this.server = mc.createServer({ + host: spec.host || '0.0.0.0', + 'online-mode': spec.onlineMode, + enforceSecureProfile: false, + port: spec.port, + version: this.config.server.version, + maxPlayers: spec.maxClients, + motd: '§bFlayerProxy §7Spectators', + hideErrors: true, + errorHandler: (client, err) => { + log.error(`Spectator error (${client.username || '?'}):`, err.message); + try { + client.end(err.message); + } catch { + /* ignore */ + } + }, + }); + + this.server.on('login', (client) => { + wrapClientEnd(client); + + const slot = this.canAcceptClient?.() ?? { ok: true }; + if (!slot.ok) { + log.warn(`Rejecting spectator ${client.username || 'client'}: ${slot.reason}`); + client.end(slot.reason); + return; + } + + client.prependOnceListener('login_acknowledged', () => { + const packets = this.worldState.getRawConfigPacketsForReplay(); + if (packets.length === 0) return; + for (const { name, buffer } of packets) { + try { + client.writeRaw(buffer); + } catch (err) { + log.error(`Raw config '${name}' for ${client.username}:`, err.message); + } + } + }); + }); + + this.server.on('playerJoin', (client) => { + disableInboundChatValidation(client); + log.info(`Spectator joined: ${client.username}`); + this.hub.addSpectator(client).catch((err) => { + log.error(`Spectator setup failed for ${client.username}:`, err.message); + try { + client.end('Failed to start spectator session.'); + } catch { + /* ignore */ + } + }); + }); + + this.server.on('error', (err) => { + log.error('Spectator proxy error:', err.message); + }); + + this.server.on('listening', () => { + log.info(`Spectator proxy listening on port ${spec.port}`); + }); + } + + updateRegistryCodec(codec) { + if (!this.server?.options) return; + this.server.options.registryCodec = codec; + } + + stop() { + if (this.server) { + this.server.close(); + this.server = null; + } + } +} + +module.exports = { SpectatorProxyServer }; diff --git a/src/replay/StateReplayer.js b/src/replay/StateReplayer.js index 11a56cf..cb24f09 100644 --- a/src/replay/StateReplayer.js +++ b/src/replay/StateReplayer.js @@ -1,6 +1,7 @@ const { createLogger } = require('../utils/logger'); const { buildClientboundPositionPacket } = require('../utils/positionSync'); const { LEVEL_CHUNKS_LOAD_START } = require('../utils/handoffSync'); +const { SPECTATOR_GAMEMODE } = require('../constants/spectatorPackets'); const { POST_REPLAY_SETTLE_MS, replayPacketData, @@ -26,14 +27,25 @@ class StateReplayer { this.serverConn = serverConn; } + /** + * Replay world state for a watch-only spectator client. + * @param {object} client + * @returns {Promise} + */ + async replaySpectator(client) { + return this.replay(client, { spectator: true }); + } + /** * Replay all cached state to the given client connection. * The client should be in the 'play' state already. * * @param {object} client - minecraft-protocol client connection (from proxy server) + * @param {{ spectator?: boolean }} [options] * @returns {Promise} */ - async replay(client) { + async replay(client, options = {}) { + const spectator = options.spectator === true; const ws = this.worldState; const bot = this.serverConn?.bot; const playerState = ws.player.getState(); @@ -78,11 +90,21 @@ class StateReplayer { // 3. Abilities + permission level (entity_status 24–28 for game mode switcher) if (playerState.abilities) { - write('abilities', playerState.abilities); + let abilities = playerState.abilities; + if (spectator && typeof abilities.flags === 'number') { + abilities = { ...abilities, flags: abilities.flags | 0x0f }; + } + write('abilities', abilities); } - if (playerState.permissionStatus) { + if (playerState.permissionStatus && !spectator) { write('entity_status', playerState.permissionStatus); } + if (spectator) { + write('game_state_change', { reason: 3, gameMode: SPECTATOR_GAMEMODE }); + if (playerState.entityId != null) { + write('camera', { cameraId: playerState.entityId }); + } + } const { beforeLevel: miscEarly, levelInfo: miscLevelInfo, weatherPackets } = splitMiscReplayPackets( ws.misc.getReplayPackets() @@ -197,8 +219,8 @@ class StateReplayer { } } - // 12. Full inventory (window_items, set_slot, etc.) matches player.initInventoryMenu() at the end - if (fullInvPackets.length > 0) { + // 12. Full inventory (spectators skip — watch-only) + if (!spectator && fullInvPackets.length > 0) { log.info(`Replaying ${fullInvPackets.length} inventory packets...`); for (const pkt of fullInvPackets) { write(pkt.name, pkt.data); diff --git a/src/session/BotIdleBehavior.js b/src/session/BotIdleBehavior.js index c756d3d..9a4e2ac 100644 --- a/src/session/BotIdleBehavior.js +++ b/src/session/BotIdleBehavior.js @@ -11,10 +11,12 @@ class BotIdleBehavior { /** * @param {import('mineflayer').Bot} bot * @param {object} botConfig - config.bot + * @param {{ onSwing?: (hand: 'left'|'right') => void }} [hooks] */ - constructor(bot, botConfig) { + constructor(bot, botConfig, hooks = {}) { this.bot = bot; this.config = botConfig; + this.onSwing = hooks.onSwing; this._enabled = false; this._timer = null; this._sneakReleaseTimer = null; @@ -101,6 +103,7 @@ class BotIdleBehavior { _randomSwing() { const hand = Math.random() < 0.5 ? 'right' : 'left'; this.bot.swingArm(hand); + this.onSwing?.(hand); } } diff --git a/src/session/ServerConnection.js b/src/session/ServerConnection.js index 1f971ff..790bac7 100644 --- a/src/session/ServerConnection.js +++ b/src/session/ServerConnection.js @@ -4,6 +4,10 @@ const { createLogger } = require('../utils/logger'); const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay'); const { ChunkAckManager } = require('./ChunkAckManager'); const { BotIdleBehavior } = require('./BotIdleBehavior'); +const { + ANIMATION_SWING_MAIN_HAND, + ANIMATION_SWING_OFF_HAND, +} = require('../constants/spectatorPackets'); const log = createLogger('ServerConn'); @@ -100,7 +104,9 @@ class ServerConnection extends EventEmitter { log.info('Bot spawned in world'); this.connected = true; if (!this._idleBehavior) { - this._idleBehavior = new BotIdleBehavior(this.bot, this.config.bot); + this._idleBehavior = new BotIdleBehavior(this.bot, this.config.bot, { + onSwing: (hand) => this._emitBotSwingAnimation(hand), + }); } if (this._botControlEnabled) { this._idleBehavior.start(); @@ -163,6 +169,20 @@ class ServerConnection extends EventEmitter { + /** + * Server usually does not echo the bot's own arm swing back on its connection. + * Push a clientbound animation so spectators see idle swings. + * @param {'left'|'right'} hand + */ + _emitBotSwingAnimation(hand) { + const entityId = this.worldState.player.entityId ?? this.bot?.entity?.id; + if (entityId == null) return; + this.emit('botVisual', 'animation', { + entityId, + animation: hand === 'left' ? ANIMATION_SWING_OFF_HAND : ANIMATION_SWING_MAIN_HAND, + }); + } + /** * Enable/disable bot AI control. * When disabled, the bot stops all autonomous behavior. diff --git a/src/session/SessionManager.js b/src/session/SessionManager.js index 5532d7b..4563f62 100644 --- a/src/session/SessionManager.js +++ b/src/session/SessionManager.js @@ -1,6 +1,8 @@ const { createLogger } = require('../utils/logger'); const { ServerConnection } = require('./ServerConnection'); const { ProxyServer } = require('../proxy/ProxyServer'); +const { SpectatorProxyServer } = require('../proxy/SpectatorProxyServer'); +const { SpectatorHub } = require('../spectator/SpectatorHub'); const { WorldStateCache } = require('../state/WorldStateCache'); const { StateReplayer } = require('../replay/StateReplayer'); const { performHandoff } = require('./handoffFlow'); @@ -45,6 +47,19 @@ class SessionManager { ); this.replayer = new StateReplayer(this.worldState, this.serverConn); + if (config.spectator.enabled !== false) { + this.spectatorHub = new SpectatorHub(this.serverConn, this.worldState, this.replayer, config); + this.spectatorProxy = new SpectatorProxyServer( + config, + this.spectatorHub, + this.worldState, + () => this._spectatorSlotStatus(), + ); + } else { + this.spectatorHub = null; + this.spectatorProxy = null; + } + // Current client bridge (if in CLIENT_MODE) this.clientBridge = null; this.currentClient = null; @@ -59,6 +74,9 @@ class SessionManager { log.info('Starting FlayerProxy...'); this.serverConn.connect(); this.proxyServer.start(); + if (this.spectatorProxy) { + this.spectatorProxy.start(); + } } /** @@ -92,11 +110,13 @@ class SessionManager { const packets = this.worldState.getRawConfigPacketsForReplay(); const registryCount = packets.filter(p => p.name === 'registry_data').length; this.proxyServer.updateRegistryCodec({}); + this.spectatorProxy?.updateRegistryCodec({}); log.info(`Captured ${packets.length} raw config packets (${registryCount} registries) from server`); } else { const registryCodec = this.worldState.buildRegistryCodec(); if (registryCodec) { this.proxyServer.updateRegistryCodec(registryCodec); + this.spectatorProxy?.updateRegistryCodec(registryCodec); } else { log.warn('No registry_data captured from server — proxy clients will use minecraft-data defaults'); } @@ -116,6 +136,7 @@ class SessionManager { } this._cleanupClient(); + this.spectatorHub?.kickAll(`Server disconnected: ${disconnectReasonText(reason)}`); this._transitionTo(State.INIT); this._scheduleReconnect(5); }); @@ -130,6 +151,7 @@ class SessionManager { } this._cleanupClient(); + this.spectatorHub?.kickAll(`Kicked from server: ${disconnectReasonText(reason)}`); this._transitionTo(State.INIT); this._scheduleReconnect(15); }); @@ -238,6 +260,38 @@ class SessionManager { this.proxyServer.releaseClient(client); } + /** + * Spectators may join when the bot session is live and watchable. + * @returns {{ ok: boolean, reason?: string }} + */ + _spectatorSlotStatus() { + if (!this.serverConn.connected) { + return { ok: false, reason: 'Proxy is not connected to the server.' }; + } + if (this.state === State.INIT) { + return { + ok: false, + reason: 'Proxy is still connecting to the server. Try again in a moment.', + }; + } + if (this.state === State.HANDOFF) { + return { ok: false, reason: 'Session is handing off — try again in a moment.' }; + } + if (this.state === State.CLIENT_MODE) { + return { ok: true }; + } + if (this.state === State.BOT_MODE && this.serverConn._botControlEnabled) { + return { ok: true }; + } + if (this.state === State.BOT_MODE) { + return { + ok: false, + reason: 'A player is connected on the main proxy port. Spectate after they disconnect.', + }; + } + return { ok: false, reason: 'Spectator mode is unavailable.' }; + } + /** * Handle a new Java client connection from the proxy server. */ @@ -338,6 +392,8 @@ class SessionManager { } log.info('Shutting down FlayerProxy...'); this._cleanupClient(); + this.spectatorHub?.stop(); + this.spectatorProxy?.stop(); this.proxyServer.stop(); this.serverConn.disconnect(); } diff --git a/src/spectator/SpectatorHub.js b/src/spectator/SpectatorHub.js new file mode 100644 index 0000000..66d8365 --- /dev/null +++ b/src/spectator/SpectatorHub.js @@ -0,0 +1,237 @@ +const { createLogger } = require('../utils/logger'); +const { RAW_FORWARD_PACKETS } = require('../constants/rawPackets'); +const { + SPECTATOR_ALLOWED_C2S, + SPECTATOR_MOVEMENT_C2S, +} = require('../constants/spectatorPackets'); +const { + buildClientboundPositionPacket, + chunkCoordsFromBlock, + ensureClientViewIncludesChunk, + updateClientViewPosition, +} = require('../utils/positionSync'); +const { disableInboundChatValidation } = require('../utils/chatRelay'); + +const log = createLogger('Spectator'); + +/** + * Fans upstream S2C packets to multiple spectator clients (watch-only). + */ +class SpectatorHub { + /** + * @param {import('../session/ServerConnection').ServerConnection} serverConn + * @param {import('../state/WorldStateCache').WorldStateCache} worldState + * @param {import('../replay/StateReplayer').StateReplayer} replayer + * @param {object} config + */ + constructor(serverConn, worldState, replayer, config) { + this.serverConn = serverConn; + this.worldState = worldState; + this.replayer = replayer; + this.config = config; + this._botVisualHandler = (name, data) => this._forwardToSpectators(name, data, null); + serverConn.on('botVisual', this._botVisualHandler); + /** @type {Map} */ + this._spectators = new Map(); + this._serverHandler = null; + this._snapInterval = null; + } + + get count() { + return this._spectators.size; + } + + _maxSpectators() { + return this.config.spectator?.maxClients ?? 20; + } + + _getViewDistance() { + return ( + this.worldState.misc.viewDistance?.viewDistance ?? + this.config.bot?.viewDistance ?? + 10 + ); + } + + _botBlockCoords() { + const pos = this.serverConn.bot?.entity?.position; + if (!pos) return null; + return { x: pos.x, z: pos.z }; + } + + async addSpectator(client) { + if (this._spectators.size >= this._maxSpectators()) { + client.end('Spectator slots are full.'); + return; + } + + disableInboundChatValidation(client); + + const state = { view: { chunkX: null, chunkZ: null }, teleportId: 0 }; + this._installClientGuard(client, state); + + await this.replayer.replaySpectator(client); + + this._spectators.set(client, state); + this._syncViewFromBot(state.view); + this._lockCamera(client); + this._snapPosition(client, state); + this._attachServerFanout(); + this._startSnapLoop(); + + client.on('end', () => this.removeSpectator(client)); + log.info(`Spectator active: ${client.username} (${this._spectators.size} watching)`); + } + + removeSpectator(client) { + if (!this._spectators.delete(client)) return; + log.info(`Spectator left: ${client.username} (${this._spectators.size} watching)`); + if (this._spectators.size === 0) { + this._detachServerFanout(); + this._stopSnapLoop(); + } + } + + kickAll(reason) { + for (const client of [...this._spectators.keys()]) { + try { + client.end(reason); + } catch { + /* ignore */ + } + } + this._spectators.clear(); + this._detachServerFanout(); + } + + stop() { + if (this._botVisualHandler) { + this.serverConn.removeListener('botVisual', this._botVisualHandler); + this._botVisualHandler = null; + } + this.kickAll('Proxy shutting down'); + } + + _installClientGuard(client, state) { + client.prependListener('packet', (data, meta) => { + if (meta.state !== 'play') return; + if (SPECTATOR_ALLOWED_C2S.has(meta.name)) return; + + if (SPECTATOR_MOVEMENT_C2S.has(meta.name)) { + this._lockCamera(client); + this._snapPosition(client, state); + } + }); + } + + _lockCamera(client) { + const entityId = this.worldState.player.entityId; + if (entityId == null || client.ended || client.state !== 'play') return; + try { + client.write('camera', { cameraId: entityId }); + } catch (err) { + log.debug(`camera lock failed for ${client.username}: ${err.message}`); + } + } + + _snapPosition(client, state) { + const bot = this.serverConn.bot; + if (!bot?.entity?.position || client.ended || client.state !== 'play') return; + const packet = buildClientboundPositionPacket(bot, ++state.teleportId); + if (!packet) return; + try { + client.write('position', packet); + } catch (err) { + log.debug(`position snap failed for ${client.username}: ${err.message}`); + } + } + + _startSnapLoop() { + if (this._snapInterval) return; + this._snapInterval = setInterval(() => { + if (this._spectators.size === 0) { + this._stopSnapLoop(); + return; + } + for (const [client, state] of this._spectators) { + if (client.ended || client.state !== 'play') continue; + this._lockCamera(client); + this._snapPosition(client, state); + } + }, 1000); + } + + _stopSnapLoop() { + if (!this._snapInterval) return; + clearInterval(this._snapInterval); + this._snapInterval = null; + } + + _syncViewFromBot(view) { + const block = this._botBlockCoords(); + if (!block) return; + const { chunkX, chunkZ } = chunkCoordsFromBlock(block.x, block.z); + view.chunkX = chunkX; + view.chunkZ = chunkZ; + } + + _attachServerFanout() { + if (this._serverHandler) return; + this._serverHandler = (name, data, buffer) => { + this._forwardToSpectators(name, data, buffer); + }; + this.serverConn.on('serverPacket', this._serverHandler); + } + + _detachServerFanout() { + if (!this._serverHandler) return; + this.serverConn.removeListener('serverPacket', this._serverHandler); + this._serverHandler = null; + } + + _forwardToSpectators(name, data, buffer) { + if (this._spectators.size === 0) return; + + const block = this._botBlockCoords(); + const viewDistance = this._getViewDistance(); + + for (const [client, state] of this._spectators) { + if (client.ended || client.state !== 'play') continue; + + try { + if (name === 'position' && data.x != null && data.z != null) { + const { chunkX, chunkZ } = chunkCoordsFromBlock(data.x, data.z); + updateClientViewPosition(client, chunkX, chunkZ, state.view); + this._lockCamera(client); + } + + if (name === 'update_view_position') { + state.view.chunkX = data.chunkX; + state.view.chunkZ = data.chunkZ; + } + + if (name === 'map_chunk' && data.x != null && data.z != null && block) { + ensureClientViewIncludesChunk( + client, + block.x, + block.z, + data.x, + data.z, + viewDistance, + state.view + ); + } + + if (buffer && RAW_FORWARD_PACKETS.has(name)) { + client.writeRaw(buffer); + } else { + client.write(name, data); + } + } catch (err) { + log.debug(`Spectator forward ${name} to ${client.username}: ${err.message}`); + } + } + } +} + +module.exports = { SpectatorHub };