spectator port
This commit is contained in:
@@ -109,6 +109,8 @@ Copy the template structure and configure your parameters in `config.json` in th
|
|||||||
* **`bot`**: Bot behavior settings.
|
* **`bot`**: Bot behavior settings.
|
||||||
* `antiAfk`: When no client is connected, randomly turns, sneaks, and swings so the bot stays active.
|
* `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).
|
* `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.
|
* **`cache`**: Memory usage controls for caching the world.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"onlineMode": true,
|
"onlineMode": true,
|
||||||
"maxClients": 1
|
"maxClients": 1
|
||||||
},
|
},
|
||||||
|
"spectator": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 25568,
|
||||||
|
"onlineMode": true,
|
||||||
|
"maxClients": 20
|
||||||
|
},
|
||||||
"sniffer": {
|
"sniffer": {
|
||||||
"port": 25567,
|
"port": 25567,
|
||||||
"onlineMode": false,
|
"onlineMode": false,
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ function loadConfig() {
|
|||||||
|
|
||||||
// Apply defaults
|
// Apply defaults
|
||||||
config.proxy = Object.assign({ host: '0.0.0.0', port: 25566, onlineMode: false, maxClients: 1 }, config.proxy);
|
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(
|
config.bot = Object.assign(
|
||||||
{
|
{
|
||||||
antiAfk: true,
|
antiAfk: true,
|
||||||
|
|||||||
39
src/constants/spectatorPackets.js
Normal file
39
src/constants/spectatorPackets.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -19,7 +19,10 @@ let config;
|
|||||||
try {
|
try {
|
||||||
config = loadConfig();
|
config = loadConfig();
|
||||||
log.info(`Loaded config: server=${config.server.host}:${config.server.port} version=${config.server.version}`);
|
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) {
|
} catch (err) {
|
||||||
log.error(`Failed to load config: ${err.message}`);
|
log.error(`Failed to load config: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
105
src/proxy/SpectatorProxyServer.js
Normal file
105
src/proxy/SpectatorProxyServer.js
Normal file
@@ -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 };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const { createLogger } = require('../utils/logger');
|
const { createLogger } = require('../utils/logger');
|
||||||
const { buildClientboundPositionPacket } = require('../utils/positionSync');
|
const { buildClientboundPositionPacket } = require('../utils/positionSync');
|
||||||
const { LEVEL_CHUNKS_LOAD_START } = require('../utils/handoffSync');
|
const { LEVEL_CHUNKS_LOAD_START } = require('../utils/handoffSync');
|
||||||
|
const { SPECTATOR_GAMEMODE } = require('../constants/spectatorPackets');
|
||||||
const {
|
const {
|
||||||
POST_REPLAY_SETTLE_MS,
|
POST_REPLAY_SETTLE_MS,
|
||||||
replayPacketData,
|
replayPacketData,
|
||||||
@@ -26,14 +27,25 @@ class StateReplayer {
|
|||||||
this.serverConn = serverConn;
|
this.serverConn = serverConn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay world state for a watch-only spectator client.
|
||||||
|
* @param {object} client
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async replaySpectator(client) {
|
||||||
|
return this.replay(client, { spectator: true });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replay all cached state to the given client connection.
|
* Replay all cached state to the given client connection.
|
||||||
* The client should be in the 'play' state already.
|
* The client should be in the 'play' state already.
|
||||||
*
|
*
|
||||||
* @param {object} client - minecraft-protocol client connection (from proxy server)
|
* @param {object} client - minecraft-protocol client connection (from proxy server)
|
||||||
|
* @param {{ spectator?: boolean }} [options]
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async replay(client) {
|
async replay(client, options = {}) {
|
||||||
|
const spectator = options.spectator === true;
|
||||||
const ws = this.worldState;
|
const ws = this.worldState;
|
||||||
const bot = this.serverConn?.bot;
|
const bot = this.serverConn?.bot;
|
||||||
const playerState = ws.player.getState();
|
const playerState = ws.player.getState();
|
||||||
@@ -78,11 +90,21 @@ class StateReplayer {
|
|||||||
|
|
||||||
// 3. Abilities + permission level (entity_status 24–28 for game mode switcher)
|
// 3. Abilities + permission level (entity_status 24–28 for game mode switcher)
|
||||||
if (playerState.abilities) {
|
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);
|
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(
|
const { beforeLevel: miscEarly, levelInfo: miscLevelInfo, weatherPackets } = splitMiscReplayPackets(
|
||||||
ws.misc.getReplayPackets()
|
ws.misc.getReplayPackets()
|
||||||
@@ -197,8 +219,8 @@ class StateReplayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12. Full inventory (window_items, set_slot, etc.) matches player.initInventoryMenu() at the end
|
// 12. Full inventory (spectators skip — watch-only)
|
||||||
if (fullInvPackets.length > 0) {
|
if (!spectator && fullInvPackets.length > 0) {
|
||||||
log.info(`Replaying ${fullInvPackets.length} inventory packets...`);
|
log.info(`Replaying ${fullInvPackets.length} inventory packets...`);
|
||||||
for (const pkt of fullInvPackets) {
|
for (const pkt of fullInvPackets) {
|
||||||
write(pkt.name, pkt.data);
|
write(pkt.name, pkt.data);
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ class BotIdleBehavior {
|
|||||||
/**
|
/**
|
||||||
* @param {import('mineflayer').Bot} bot
|
* @param {import('mineflayer').Bot} bot
|
||||||
* @param {object} botConfig - config.bot
|
* @param {object} botConfig - config.bot
|
||||||
|
* @param {{ onSwing?: (hand: 'left'|'right') => void }} [hooks]
|
||||||
*/
|
*/
|
||||||
constructor(bot, botConfig) {
|
constructor(bot, botConfig, hooks = {}) {
|
||||||
this.bot = bot;
|
this.bot = bot;
|
||||||
this.config = botConfig;
|
this.config = botConfig;
|
||||||
|
this.onSwing = hooks.onSwing;
|
||||||
this._enabled = false;
|
this._enabled = false;
|
||||||
this._timer = null;
|
this._timer = null;
|
||||||
this._sneakReleaseTimer = null;
|
this._sneakReleaseTimer = null;
|
||||||
@@ -101,6 +103,7 @@ class BotIdleBehavior {
|
|||||||
_randomSwing() {
|
_randomSwing() {
|
||||||
const hand = Math.random() < 0.5 ? 'right' : 'left';
|
const hand = Math.random() < 0.5 ? 'right' : 'left';
|
||||||
this.bot.swingArm(hand);
|
this.bot.swingArm(hand);
|
||||||
|
this.onSwing?.(hand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const { createLogger } = require('../utils/logger');
|
|||||||
const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay');
|
const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay');
|
||||||
const { ChunkAckManager } = require('./ChunkAckManager');
|
const { ChunkAckManager } = require('./ChunkAckManager');
|
||||||
const { BotIdleBehavior } = require('./BotIdleBehavior');
|
const { BotIdleBehavior } = require('./BotIdleBehavior');
|
||||||
|
const {
|
||||||
|
ANIMATION_SWING_MAIN_HAND,
|
||||||
|
ANIMATION_SWING_OFF_HAND,
|
||||||
|
} = require('../constants/spectatorPackets');
|
||||||
|
|
||||||
const log = createLogger('ServerConn');
|
const log = createLogger('ServerConn');
|
||||||
|
|
||||||
@@ -100,7 +104,9 @@ class ServerConnection extends EventEmitter {
|
|||||||
log.info('Bot spawned in world');
|
log.info('Bot spawned in world');
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
if (!this._idleBehavior) {
|
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) {
|
if (this._botControlEnabled) {
|
||||||
this._idleBehavior.start();
|
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.
|
* Enable/disable bot AI control.
|
||||||
* When disabled, the bot stops all autonomous behavior.
|
* When disabled, the bot stops all autonomous behavior.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const { createLogger } = require('../utils/logger');
|
const { createLogger } = require('../utils/logger');
|
||||||
const { ServerConnection } = require('./ServerConnection');
|
const { ServerConnection } = require('./ServerConnection');
|
||||||
const { ProxyServer } = require('../proxy/ProxyServer');
|
const { ProxyServer } = require('../proxy/ProxyServer');
|
||||||
|
const { SpectatorProxyServer } = require('../proxy/SpectatorProxyServer');
|
||||||
|
const { SpectatorHub } = require('../spectator/SpectatorHub');
|
||||||
const { WorldStateCache } = require('../state/WorldStateCache');
|
const { WorldStateCache } = require('../state/WorldStateCache');
|
||||||
const { StateReplayer } = require('../replay/StateReplayer');
|
const { StateReplayer } = require('../replay/StateReplayer');
|
||||||
const { performHandoff } = require('./handoffFlow');
|
const { performHandoff } = require('./handoffFlow');
|
||||||
@@ -45,6 +47,19 @@ class SessionManager {
|
|||||||
);
|
);
|
||||||
this.replayer = new StateReplayer(this.worldState, this.serverConn);
|
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)
|
// Current client bridge (if in CLIENT_MODE)
|
||||||
this.clientBridge = null;
|
this.clientBridge = null;
|
||||||
this.currentClient = null;
|
this.currentClient = null;
|
||||||
@@ -59,6 +74,9 @@ class SessionManager {
|
|||||||
log.info('Starting FlayerProxy...');
|
log.info('Starting FlayerProxy...');
|
||||||
this.serverConn.connect();
|
this.serverConn.connect();
|
||||||
this.proxyServer.start();
|
this.proxyServer.start();
|
||||||
|
if (this.spectatorProxy) {
|
||||||
|
this.spectatorProxy.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,11 +110,13 @@ class SessionManager {
|
|||||||
const packets = this.worldState.getRawConfigPacketsForReplay();
|
const packets = this.worldState.getRawConfigPacketsForReplay();
|
||||||
const registryCount = packets.filter(p => p.name === 'registry_data').length;
|
const registryCount = packets.filter(p => p.name === 'registry_data').length;
|
||||||
this.proxyServer.updateRegistryCodec({});
|
this.proxyServer.updateRegistryCodec({});
|
||||||
|
this.spectatorProxy?.updateRegistryCodec({});
|
||||||
log.info(`Captured ${packets.length} raw config packets (${registryCount} registries) from server`);
|
log.info(`Captured ${packets.length} raw config packets (${registryCount} registries) from server`);
|
||||||
} else {
|
} else {
|
||||||
const registryCodec = this.worldState.buildRegistryCodec();
|
const registryCodec = this.worldState.buildRegistryCodec();
|
||||||
if (registryCodec) {
|
if (registryCodec) {
|
||||||
this.proxyServer.updateRegistryCodec(registryCodec);
|
this.proxyServer.updateRegistryCodec(registryCodec);
|
||||||
|
this.spectatorProxy?.updateRegistryCodec(registryCodec);
|
||||||
} else {
|
} else {
|
||||||
log.warn('No registry_data captured from server — proxy clients will use minecraft-data defaults');
|
log.warn('No registry_data captured from server — proxy clients will use minecraft-data defaults');
|
||||||
}
|
}
|
||||||
@@ -116,6 +136,7 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._cleanupClient();
|
this._cleanupClient();
|
||||||
|
this.spectatorHub?.kickAll(`Server disconnected: ${disconnectReasonText(reason)}`);
|
||||||
this._transitionTo(State.INIT);
|
this._transitionTo(State.INIT);
|
||||||
this._scheduleReconnect(5);
|
this._scheduleReconnect(5);
|
||||||
});
|
});
|
||||||
@@ -130,6 +151,7 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._cleanupClient();
|
this._cleanupClient();
|
||||||
|
this.spectatorHub?.kickAll(`Kicked from server: ${disconnectReasonText(reason)}`);
|
||||||
this._transitionTo(State.INIT);
|
this._transitionTo(State.INIT);
|
||||||
this._scheduleReconnect(15);
|
this._scheduleReconnect(15);
|
||||||
});
|
});
|
||||||
@@ -238,6 +260,38 @@ class SessionManager {
|
|||||||
this.proxyServer.releaseClient(client);
|
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.
|
* Handle a new Java client connection from the proxy server.
|
||||||
*/
|
*/
|
||||||
@@ -338,6 +392,8 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
log.info('Shutting down FlayerProxy...');
|
log.info('Shutting down FlayerProxy...');
|
||||||
this._cleanupClient();
|
this._cleanupClient();
|
||||||
|
this.spectatorHub?.stop();
|
||||||
|
this.spectatorProxy?.stop();
|
||||||
this.proxyServer.stop();
|
this.proxyServer.stop();
|
||||||
this.serverConn.disconnect();
|
this.serverConn.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
237
src/spectator/SpectatorHub.js
Normal file
237
src/spectator/SpectatorHub.js
Normal file
@@ -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<object, { view: { chunkX: number|null, chunkZ: number|null }, teleportId: number }>} */
|
||||||
|
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 };
|
||||||
Reference in New Issue
Block a user