Compare commits
4 Commits
bf2bed5599
...
373378de15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373378de15 | ||
|
|
a02a1758b5 | ||
|
|
5cdcfd836d | ||
|
|
2a9b996453 |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
295
codebase_map.md
295
codebase_map.md
@@ -9,15 +9,19 @@ This document provides a comprehensive mapping of all the classes, functions, an
|
||||
- [Architecture & Interaction Diagrams](#-architecture--interaction-diagrams)
|
||||
- [High-Level Core System](#1-high-level-core-system-diagram)
|
||||
- [Client Handoff Flow](#2-client-handoff-flow-diagram)
|
||||
- [MITM Sniffer Architecture](#3-mitm-sniffer-architecture-diagram)
|
||||
- [Spectator Watch Flow](#3-spectator-watch-flow-diagram)
|
||||
- [MITM Sniffer Architecture](#4-mitm-sniffer-architecture-diagram)
|
||||
- [Cross-Cutting Behavior](#-cross-cutting-behavior)
|
||||
- [Detailed File Mapping](#-detailed-file-mapping)
|
||||
- [Root Scripts & Configuration](#1-root-scripts--configuration) — `src/index.js`, `src/config.js`
|
||||
- [session — Session State Machine](#2-session--session-state-machine) — `SessionManager`, `ServerConnection`, `ChunkAckManager`, `MovementRelay`, `handoffFlow`
|
||||
- [session — Session State Machine](#2-session--session-state-machine) — `SessionManager`, `ServerConnection`, `BotIdleBehavior`, `ChunkAckManager`, `MovementRelay`, `handoffFlow`
|
||||
- [proxy — Client Connection Proxy](#3-proxy--client-connection-proxy) — `ProxyServer`, `ClientBridge`
|
||||
- [state — World State Caching](#4-state--world-state-caching) — `WorldStateCache`, `ChunkCache`, `EntityCache`, `PlayerStateCache`, `InventoryCache`, `MiscCache`, `JoinSyncCache`, `WorldBorderCache`, `ScoreboardCache`
|
||||
- [replay — Client Handoff Replay](#5-replay--client-handoff-replay) — `StateReplayer`, `replayChunks`, `replayHelpers`
|
||||
- [utils — Helper Utilities](#6-utils--helper-utilities) — `angles`, `chatRelay`, `clientDisconnect`, `handoffSync`, `logger`, `positionSync`
|
||||
- [sniffer — MITM Packet Sniffer](#7-sniffer--mitm-packet-sniffer) — `MitmProxy`, `TransparentProxy`, `StreamTap`, `PacketLog`, and relay modules
|
||||
- [spectator — Watch-Only Multi-Client Proxy](#4-spectator--watch-only-multi-client-proxy) — `SpectatorProxyServer`, `SpectatorHub`, `spectatorPackets`
|
||||
- [state — World State Caching](#5-state--world-state-caching) — `WorldStateCache`, `ChunkCache`, `chunkMerge`, entity/player/inventory/misc caches
|
||||
- [replay — Client Handoff Replay](#6-replay--client-handoff-replay) — `StateReplayer`, `replayChunks`, `replayHelpers`
|
||||
- [utils — Helper Utilities](#7-utils--helper-utilities) — `angles`, `chatRelay`, `clientDisconnect`, `handoffSync`, `logger`, `positionSync`
|
||||
- [constants — Shared Packet Sets](#8-constants--shared-packet-sets) — `rawPackets`, `spectatorPackets`
|
||||
- [sniffer — MITM Packet Sniffer](#9-sniffer--mitm-packet-sniffer) — `MitmProxy`, `TransparentProxy`, `StreamTap`, `PacketLog`, and relay modules
|
||||
|
||||
---
|
||||
|
||||
@@ -25,7 +29,7 @@ This document provides a comprehensive mapping of all the classes, functions, an
|
||||
|
||||
### 1. High-Level Core System Diagram
|
||||
|
||||
This diagram shows how the core components of FlayerProxy (`SessionManager`, `ProxyServer`, `ServerConnection`, `WorldStateCache`, `StateReplayer`, and `ClientBridge`) cooperate to manage the proxy's dual-mode lifecycle.
|
||||
This diagram shows how the core components cooperate across **play** (single client, port 25566), **spectator** (multi-client watch-only, port 25568), and **bot mode** (idle behaviour when no player is connected).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -33,31 +37,42 @@ graph TD
|
||||
classDef helper fill:#f5f0d4,stroke:#7a6b1a,stroke-width:1px;
|
||||
classDef external fill:#e1d5e7,stroke:#9673a6,stroke-width:1px;
|
||||
|
||||
Client["Minecraft Client (Port 25566)"]:::external
|
||||
Client["Play Client (25566, max 1)"]:::external
|
||||
Specs["Spectator Clients (25568, max N)"]:::external
|
||||
Server["Target Minecraft Server"]:::external
|
||||
|
||||
subgraph FlayerProxy ["FlayerProxy Core"]
|
||||
SM["[SessionManager]"]:::main
|
||||
SC["[ServerConnection]"]:::main
|
||||
PS["[ProxyServer]"]:::main
|
||||
SPS["[SpectatorProxyServer]"]:::main
|
||||
SH["[SpectatorHub]"]:::main
|
||||
WSC["[WorldStateCache]"]:::main
|
||||
SR["[StateReplayer]"]:::main
|
||||
CB["[ClientBridge]"]:::main
|
||||
IB["[BotIdleBehavior]"]:::helper
|
||||
end
|
||||
|
||||
SM -->|Orchestrates| SC
|
||||
SM -->|Orchestrates| PS
|
||||
SM -->|Orchestrates| SPS
|
||||
SM -->|Orchestrates| WSC
|
||||
SM -->|Orchestrates| SR
|
||||
SM -->|Coordinates Handoff| CB
|
||||
SM -->|Handoff| CB
|
||||
SPS -->|on join| SH
|
||||
SH -->|Uses| SR
|
||||
SH -->|Fans out S2C from| SC
|
||||
|
||||
SC -->|Holds session with| Bot["Mineflayer Bot"]:::external
|
||||
SC -->|BOT_MODE| IB
|
||||
IB -->|onSwing → botVisual| SH
|
||||
Bot -->|Connects to| Server
|
||||
SC -->|Captures play packets to| WSC
|
||||
PS -->|Listens for incoming| Client
|
||||
SR -->|Replays cache from WSC to| Client
|
||||
CB -->|Pipes C2S / S2C play packets| Client
|
||||
CB -->|Pipes C2S / S2C play packets| SC
|
||||
PS -->|Single slot login| Client
|
||||
SR -->|replay / replaySpectator| Client
|
||||
SR -->|replaySpectator| Specs
|
||||
CB -->|Bidirectional play| Client
|
||||
CB -->|Bidirectional play| SC
|
||||
```
|
||||
|
||||
---
|
||||
@@ -79,6 +94,7 @@ sequenceDiagram
|
||||
|
||||
Note over SM,SC: State: BOT_MODE (Bot AI holds session)
|
||||
Player->>PS: Connect to Proxy (Port 25566)
|
||||
Note over PS: login reserves single activeClient slot
|
||||
PS->>SM: _onClientConnect(client)
|
||||
Note over SM: State -> HANDOFF
|
||||
SM->>SC: setBotControl(false) [Disable Bot physics/AI]
|
||||
@@ -110,7 +126,40 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
### 3. MITM Sniffer Architecture Diagram
|
||||
### 3. Spectator Watch Flow Diagram
|
||||
|
||||
Spectators connect on a **separate port** (default 25568). They receive a spectator replay, then a read-only fan-out of upstream S2C packets. Movement is blocked; the camera is locked to the bot entity. Idle arm swings are synthesized locally (the server does not echo the bot's own swing).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Spec as Spectator Client
|
||||
participant SPS as SpectatorProxyServer
|
||||
participant SH as SpectatorHub
|
||||
participant SR as StateReplayer
|
||||
participant SC as ServerConnection
|
||||
participant IB as BotIdleBehavior
|
||||
|
||||
Note over SC: BOT_MODE (bot control + idle)
|
||||
Spec->>SPS: Connect (Port 25568)
|
||||
SPS->>SH: addSpectator(client)
|
||||
SH->>SR: replaySpectator(client)
|
||||
SR->>Spec: login, terrain, entities (spectator gamemode + camera)
|
||||
SH->>Spec: camera lock + position snap to bot
|
||||
SH->>SC: listen serverPacket + botVisual
|
||||
loop Live watch
|
||||
SC->>SH: serverPacket (chunks, entities, position, …)
|
||||
SH->>Spec: forward S2C (writeRaw where needed)
|
||||
IB->>SC: swingArm → emit botVisual animation
|
||||
SC->>SH: botVisual animation
|
||||
SH->>Spec: entity swing animation
|
||||
Spec--xSH: movement C2S dropped / camera re-lock
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. MITM Sniffer Architecture Diagram
|
||||
|
||||
The Packet Sniffer has two modes of operation:
|
||||
|
||||
@@ -141,13 +190,51 @@ graph LR
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Cross-Cutting Behavior
|
||||
|
||||
Summary of policies that span multiple modules (not obvious from individual class tables).
|
||||
|
||||
### Ports and connection policy
|
||||
|
||||
| Port (default) | Listener | Max clients | When accepted |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **25566** | [ProxyServer](file:///home/seb/flayerproxy/src/proxy/ProxyServer.js) | **1** | `BOT_MODE` only; slot reserved on `login`, released on reject/disconnect |
|
||||
| **25568** | [SpectatorProxyServer](file:///home/seb/flayerproxy/src/proxy/SpectatorProxyServer.js) | **20** (configurable) | Upstream connected and not `INIT`/`HANDOFF`; allowed in `BOT_MODE` (bot control) or `CLIENT_MODE` |
|
||||
|
||||
Disable spectators with `spectator.enabled: false` in `config.json`.
|
||||
|
||||
### Chunk cache vs live forwarding
|
||||
|
||||
| Phase | `map_chunk` behavior |
|
||||
| :--- | :--- |
|
||||
| **Bot session (cache)** | [ChunkCache](file:///home/seb/flayerproxy/src/state/ChunkCache.js) stores only chunks within view distance of the bot view center (`update_view_position` or player position + `update_view_distance`). Out-of-view chunks are skipped on ingest and pruned via `forgetOutsideView`. |
|
||||
| **Handoff replay** | [StateReplayer](file:///home/seb/flayerproxy/src/replay/StateReplayer.js) + `getChunksForReplay()` send **only** in-view cached chunks (nearest-first). |
|
||||
| **CLIENT_MODE bridge** | [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js) forwards **all** upstream `map_chunk` packets. May send `update_view_position` so the client accepts chunks outside its local cache radius. |
|
||||
|
||||
Chunks are **server-pushed**; the bot/client mainly acks via `chunk_batch_received` and movement/view packets.
|
||||
|
||||
### Block/light merge and `structuredClone`
|
||||
|
||||
`map_chunk` binary fields (`chunkData`, heightmaps, etc.) must stay as Node `Buffer` instances for prismarine-chunk / smart-buffer. `structuredClone` in the cache path can turn them into `Uint8Array`, which breaks merges (`Invalid Buffer provided in SmartBufferOptions`). [chunkMerge.js](file:///home/seb/flayerproxy/src/state/chunkMerge.js) normalizes via `asBuffer()` / `normalizeMapChunkPacket()` before column load and merge.
|
||||
|
||||
### Spectator watch (movement and visuals)
|
||||
|
||||
- **Movement:** Vanilla spectator free-cam is largely client-side. The proxy cannot rely on dropping C2S alone; [SpectatorHub](file:///home/seb/flayerproxy/src/spectator/SpectatorHub.js) locks the camera to the bot entity (`camera` packet), snaps position on movement C2S, and runs a 1s correction loop.
|
||||
- **Idle swing:** The server does not echo the bot’s own arm swing. [BotIdleBehavior](file:///home/seb/flayerproxy/src/session/BotIdleBehavior.js) → [ServerConnection._emitBotSwingAnimation](file:///home/seb/flayerproxy/src/session/ServerConnection.js) → `botVisual` → spectator `animation` fan-out.
|
||||
|
||||
### Bot idle (no play client)
|
||||
|
||||
While in `BOT_MODE` with bot control enabled, [BotIdleBehavior](file:///home/seb/flayerproxy/src/session/BotIdleBehavior.js) runs random look / sneak / swing on a timer (`config.bot.antiAfk*`). Started/stopped with [ServerConnection.setBotControl](file:///home/seb/flayerproxy/src/session/ServerConnection.js).
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Detailed File Mapping
|
||||
|
||||
### 1. Root Scripts & Configuration
|
||||
|
||||
#### 📄 [src/index.js](file:///home/seb/flayerproxy/src/index.js)
|
||||
|
||||
The main entry point for the application. Loads system config and handles errors, starts the core [SessionManager](file:///home/seb/flayerproxy/src/session/SessionManager.js), hooks process event signals (`SIGINT`, `SIGTERM`) for a graceful shutdown, and captures `uncaughtException` / `unhandledRejection` warnings.
|
||||
The main entry point for the application. Loads system config, logs play and spectator proxy ports, starts [SessionManager](file:///home/seb/flayerproxy/src/session/SessionManager.js), hooks `SIGINT`/`SIGTERM` for graceful shutdown, and captures `uncaughtException` / `unhandledRejection`.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
@@ -159,7 +246,9 @@ Handles configuration loading, validation, and parsing.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `loadConfig()` | Synchronously reads `config.json`, validates host, port, version, and auth configuration, applies default options, and returns the configuration object. |
|
||||
| `loadConfig()` | Synchronously reads `config.json`, validates host, port, version, and auth configuration, applies defaults for `proxy`, `spectator`, `bot` (anti-Afk intervals), and `cache`, and returns the configuration object. |
|
||||
|
||||
**Default config sections:** `proxy` (port 25566, `maxClients: 1`), `spectator` (port 25568, `maxClients: 20`, `enabled: true`), `bot` (`antiAfk`, `antiAfkMinInterval`, `antiAfkMaxInterval`, `viewDistance`).
|
||||
|
||||
---
|
||||
|
||||
@@ -171,14 +260,17 @@ Orchestrates the dual-mode proxy state machine: `INIT` ↔ `BOT_MODE` ↔ `HANDO
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config)` | Initializes state machine with configuration. |
|
||||
| `start()` | Establishes connections to the upstream Minecraft server and starts the proxy server. |
|
||||
| `constructor(config)` | Initializes state machine, play proxy, optional spectator proxy/hub, and replayer. |
|
||||
| `start()` | Connects upstream bot, starts play proxy (25566), and spectator proxy (25568) if enabled. |
|
||||
| `_scheduleReconnect(delaySec)` | Schedules a timed reconnect sequence if the connection is lost. |
|
||||
| `_setupServerEvents()` | Hooks upstream server events (`connected`, `disconnected`, `kicked`, `error`, `death`, `respawn`). |
|
||||
| `_primeChunksNearBot()` | Triggers server movement packets to verify that the chunk cache is loaded near the bot before handing off. |
|
||||
| `_refreshClientAfterBotRespawn()` | Re-aligns position and client view in case the bot respawns while a client is connected. |
|
||||
| `_onClientConnect(client)` | Initiates the handoff sequence to transition from `BOT_MODE` to `CLIENT_MODE`. |
|
||||
| `_cleanupClient()` | Stops the packet bridge and cleans up client-related event listeners. |
|
||||
| `_clientSlotStatus()` | Returns whether the play port may accept a new login (single client, `BOT_MODE` only). |
|
||||
| `_spectatorSlotStatus()` | Returns whether the spectator port may accept logins (connected, not `INIT`/`HANDOFF`; `BOT_MODE` with bot control or `CLIENT_MODE`). |
|
||||
| `_rejectClient(client, reason)` | Kicks play client and releases `ProxyServer.activeClient` slot. |
|
||||
| `_onClientConnect(client)` | Validates play slot, then handoff `BOT_MODE` → `CLIENT_MODE`. |
|
||||
| `_cleanupClient()` | Stops bridge, releases play client slot, clears `currentClient`. |
|
||||
| `_transitionTo(newState)` | Transitions the machine state and logs status summaries. |
|
||||
| `stop()` | Gracefully halts all services. |
|
||||
|
||||
@@ -192,8 +284,9 @@ Manages the persistent Mineflayer bot connection to the target server.
|
||||
| `connect()` | Spawns the bot connection via Mineflayer. |
|
||||
| `_setupConfigCapture()` | Caches configuration-phase registry data and tags. |
|
||||
| `_setupPacketCapture()` | Hooks play packets and routes them directly to the state cache. |
|
||||
| `_setupBotEvents()` | Listens for bot lifecycle events (`spawn`, `end`, `kicked`, `error`, `death`, and chat logging). |
|
||||
| `setBotControl(enabled)` | Enables/disables physics and AI on the Mineflayer bot. |
|
||||
| `_setupBotEvents()` | Listens for bot lifecycle events; creates [BotIdleBehavior](file:///home/seb/flayerproxy/src/session/BotIdleBehavior.js) on spawn. |
|
||||
| `_emitBotSwingAnimation(hand)` | Emits `botVisual` (`animation` packet) so spectators see idle swings (server does not echo self-swing). |
|
||||
| `setBotControl(enabled)` | Enables/disables physics; starts/stops [BotIdleBehavior](file:///home/seb/flayerproxy/src/session/BotIdleBehavior.js). |
|
||||
| `setClientDrivesChunkBatchAck(clientDrives)` | Delegates chunk batch acknowledgement control between client and Mineflayer. |
|
||||
| `flushChunkBatchAck()` | Unblocks the server chunk sender. |
|
||||
| `refreshProxyClientPermissions(client)` | Sends player permissions status packets. |
|
||||
@@ -204,6 +297,8 @@ Manages the persistent Mineflayer bot connection to the target server.
|
||||
| `writeToServer(name, data)` | Writes raw packets directly upstream. |
|
||||
| `disconnect()` | Safely disconnects the bot. |
|
||||
|
||||
**Events:** `connected`, `disconnected`, `kicked`, `error`, `death`, `respawn`, `serverPacket` `(name, data, buffer)`, `botVisual` `(name, data)` (synthetic S2C for spectators).
|
||||
|
||||
#### 🧩 [ChunkAckManager](file:///home/seb/flayerproxy/src/session/ChunkAckManager.js) `class`
|
||||
|
||||
Intercepts Mineflayer's chunk batch acknowledgement listeners to prevent double-acknowledging packets during the client session.
|
||||
@@ -229,6 +324,20 @@ Intercepts Mineflayer's chunk batch acknowledgement listeners to prevent double-
|
||||
|---|---|
|
||||
| `performHandoff({...})` | Coordinates the sequential handoff sequence: installs temporary upstream forwarding rules, primes nearby chunks, triggers the [StateReplayer](file:///home/seb/flayerproxy/src/replay/StateReplayer.js), aligns player coordinates/permissions, and spawns the [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js). |
|
||||
|
||||
#### 🧩 [BotIdleBehavior](file:///home/seb/flayerproxy/src/session/BotIdleBehavior.js) `class`
|
||||
|
||||
Random look / sneak / swing while the bot holds the session (`BOT_MODE`, no play client). Started from [ServerConnection.setBotControl(true)](file:///home/seb/flayerproxy/src/session/ServerConnection.js).
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(bot, botConfig, hooks)` | Optional `onSwing(hand)` hook for spectator animation relay. |
|
||||
| `start()` / `stop()` | Enables/disables timed idle actions (`config.bot.antiAfk`). |
|
||||
| `_scheduleNext()` | Random delay between `antiAfkMinInterval` and `antiAfkMaxInterval`. |
|
||||
| `_tick()` | Picks random action: look, sneak, or swing. |
|
||||
| `_randomLook()` | `bot.look()` with small yaw/pitch delta. |
|
||||
| `_randomSneak()` | Toggles sneak via `setControlState` for a random duration. |
|
||||
| `_randomSwing()` | `bot.swingArm()` then `onSwing` callback. |
|
||||
|
||||
---
|
||||
|
||||
### 3. proxy — Client Connection Proxy
|
||||
@@ -239,31 +348,82 @@ Listens for connection attempts from standard Minecraft Java clients.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config, onClientConnect, worldState)` | Initializes proxy server with config, client callback, and world state. |
|
||||
| `start()` | Initializes the local minecraft-protocol server (`mc.createServer`), configures pre-login listeners to replay raw config packets, and registers joining players. |
|
||||
| `constructor(config, onClientConnect, worldState, canAcceptClient)` | Play proxy; optional `canAcceptClient()` gate from [SessionManager](file:///home/seb/flayerproxy/src/session/SessionManager.js). |
|
||||
| `releaseClient(client)` | Clears `activeClient` if it matches (used on reject/disconnect). |
|
||||
| `start()` | `mc.createServer` on `config.proxy.port`; **reserves single slot on `login`**; replays raw config on `login_acknowledged`; `playerJoin` only for reserved client. |
|
||||
| `updateRegistryCodec(codec)` | Replaces the registry codec object in the protocol handler options. |
|
||||
| `stop()` | Halts client listening sockets. |
|
||||
| `stop()` | Disconnects active client and closes listener. |
|
||||
|
||||
#### 🧩 [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js) `class`
|
||||
|
||||
Manages bidirectional packet pipelines when in `CLIENT_MODE`.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(client, serverConn, worldState)` | Initializes bridge with client, server connection, and world state references. |
|
||||
| `_getViewDistance()` | Resolves the view distance from configuration or state records. |
|
||||
| `_syncClientViewFromBlockCoords(blockX, blockZ)` | Computes chunk coordinates and updates client view center position. |
|
||||
| `_syncClientViewFromBot()` | Aligns client view center with the bot entity position. |
|
||||
| `_playerBlockCoordsForView()` | Returns coordinates representing the client player's view anchor. |
|
||||
| `_ensureViewIncludesChunk(chunkX, chunkZ)` | Ensures the client's view includes target coordinates prior to sending map chunks. |
|
||||
| `enableMovement()` | Authorizes client movement and syncs view center alignment. |
|
||||
| `start()` | Sets up packet intercept listeners to route packets between legs, excluding blocked elements (e.g. `keep_alive`, `teleport_confirm`, `message_acknowledgement`) and re-signing client chat events. |
|
||||
| `_shouldForwardPlayerInfo(data)` | Filters latency updates for unknown players. |
|
||||
| `stop()` | Tears down the forwarding pipe. |
|
||||
| Method | Description |
|
||||
| --------------------------------------------------| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `constructor(client, serverConn, worldState)` | Initializes bridge with client, server connection, and world state references. |
|
||||
| `_getViewDistance()` | Resolves the view distance from configuration or state records. |
|
||||
| `_syncClientViewFromBlockCoords(blockX, blockZ)` | Computes chunk coordinates and updates client view center position. |
|
||||
| `_syncClientViewFromBot()` | Aligns client view center with the bot entity position. |
|
||||
| `_playerBlockCoordsForView()` | Returns coordinates representing the client player's view anchor. |
|
||||
| `_ensureViewIncludesChunk(chunkX, chunkZ)` | Ensures the client's view includes target coordinates prior to sending map chunks. |
|
||||
| `enableMovement()` | Authorizes client movement and syncs view center alignment. |
|
||||
| `start()` | Sets up packet intercept listeners to route packets between legs, excluding blocked elements (e.g. `keep_alive`, `teleport_confirm`, `message_acknowledgement`) and re-signing client chat events. |
|
||||
| `_shouldForwardPlayerInfo(data)` | Filters latency updates for unknown players. |
|
||||
| `stop()` | Tears down the forwarding pipe. |
|
||||
|
||||
**Play client forwarding:** All upstream `map_chunk` packets are forwarded (no radius filter). `update_view_position` may be sent before chunks outside the client cache radius.
|
||||
|
||||
---
|
||||
|
||||
### 4. state — World State Caching
|
||||
### 4. spectator — Watch-Only Multi-Client Proxy
|
||||
|
||||
#### 🧩 [SpectatorProxyServer](file:///home/seb/flayerproxy/src/proxy/SpectatorProxyServer.js) `class`
|
||||
|
||||
Separate `mc.createServer` on `config.spectator.port` (default **25568**). Allows **multiple** simultaneous spectators; no upstream connection per spectator.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config, hub, worldState, canAcceptClient)` | Spectator listener with slot/status callback from [SessionManager._spectatorSlotStatus](file:///home/seb/flayerproxy/src/session/SessionManager.js). |
|
||||
| `start()` | Binds port, replays raw config packets, calls `hub.addSpectator` on `playerJoin`. |
|
||||
| `updateRegistryCodec(codec)` | Same registry injection as play proxy. |
|
||||
| `stop()` | Closes spectator listener. |
|
||||
|
||||
#### 🧩 [SpectatorHub](file:///home/seb/flayerproxy/src/spectator/SpectatorHub.js) `class`
|
||||
|
||||
Manages spectator clients: replay, S2C fan-out, movement block, camera lock.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(serverConn, worldState, replayer, config)` | Subscribes to `serverConn` `serverPacket` and `botVisual` events. |
|
||||
| `addSpectator(client)` | `replaySpectator`, camera lock, position snap, join fan-out set. |
|
||||
| `removeSpectator(client)` | Removes client; detaches fan-out when last spectator leaves. |
|
||||
| `kickAll(reason)` / `stop()` | Disconnects all spectators; removes listeners. |
|
||||
| `_installClientGuard(client, state)` | Whitelist C2S only; on movement packets, re-lock camera + snap position. |
|
||||
| `_lockCamera(client)` | Sends `camera` with bot `entityId`. |
|
||||
| `_snapPosition(client, state)` | Sends clientbound `position` from bot entity. |
|
||||
| `_startSnapLoop()` / `_stopSnapLoop()` | Periodic camera/position correction (1s). |
|
||||
| `_forwardToSpectators(name, data, buffer)` | Fans upstream S2C to all spectators (`writeRaw` for chunk packets). |
|
||||
|
||||
#### 📄 [spectatorPackets.js](file:///home/seb/flayerproxy/src/constants/spectatorPackets.js) `constants`
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `SPECTATOR_GAMEMODE` | Gamemode id `3` for replay. |
|
||||
| `SPECTATOR_ALLOWED_C2S` | Whitelist: `chunk_batch_received`, `teleport_confirm`, `keep_alive`, etc. |
|
||||
| `SPECTATOR_BLOCKED_S2C` | Dropped S2C fan-out (e.g. `tracked_waypoint` — mid-join UPDATE without prior TRACK crashes clients). |
|
||||
| `SPECTATOR_MOVEMENT_C2S` | Movement packets that trigger camera re-lock. |
|
||||
| `ANIMATION_SWING_MAIN_HAND` / `ANIMATION_SWING_OFF_HAND` | Clientbound `animation` ids for idle swing relay. |
|
||||
|
||||
**ServerConnection events used by spectators:**
|
||||
|
||||
| Event | Payload | Description |
|
||||
|---|---|---|
|
||||
| `serverPacket` | `(name, data, buffer)` | Same stream as play cache (from upstream bot). |
|
||||
| `botVisual` | `(name, data)` | Synthetic S2C (e.g. idle `animation`) not echoed by server. |
|
||||
|
||||
---
|
||||
|
||||
### 5. state — World State Caching
|
||||
|
||||
#### 🧩 [WorldStateCache](file:///home/seb/flayerproxy/src/state/WorldStateCache.js) `class`
|
||||
|
||||
@@ -278,27 +438,41 @@ Master coordinator for world cache segments. Integrates and clears sub-caches on
|
||||
| `getRawConfigPacketsForReplay()` | Filters and returns config-phase buffers for client replay. |
|
||||
| `hasRawConfigPackets()` | Returns `true` if raw configuration buffers are cached. |
|
||||
| `handleServerPacket(name, data, buffer)` | Evaluates incoming play packets and routes them to sub-caches. |
|
||||
| `_getChunkViewContext()` | Resolves view center (`update_view_position` or player position) + view distance for cache retention. |
|
||||
| `_forgetChunksOutsideView()` | Drops cached chunks outside [isChunkWithinViewDistance](file:///home/seb/flayerproxy/src/utils/positionSync.js). |
|
||||
| `getSummary()` | Returns cache sizing info for log monitoring. |
|
||||
| `clear()` | Wipes all cached structures. |
|
||||
|
||||
#### 🧩 [ChunkCache](file:///home/seb/flayerproxy/src/state/ChunkCache.js) `class`
|
||||
|
||||
Manages loaded map chunks using an LRU cache; block and light updates are merged into chunk columns.
|
||||
Manages loaded map chunks, light maps, and block overlays using an LRU cache. Merges block/light updates via [chunkMerge.js](file:///home/seb/flayerproxy/src/state/chunkMerge.js) (prismarine-chunk columns).
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(maxChunks)` | Initializes chunk storage with LRU capacity limit. |
|
||||
| `constructor(maxChunks, options)` | LRU storage; `version` + `getWorldBounds()` for column decode. |
|
||||
| `_key(x, z)` | Computes string key for map lookups. |
|
||||
| `handleMapChunk(data, rawBuffer)` | Caches chunk data and marks it as active in the LRU tracking queue. |
|
||||
| `handleUpdateLight(data)` | Merges light updates into the cached chunk column. |
|
||||
| `forgetOutsideView(centerChunkX, centerChunkZ, viewDistance)` | Removes chunks outside server `ChunkTrackingView` distance. |
|
||||
| `handleMapChunk(data, rawBuffer, view?)` | Skips store if outside view; prunes cache; normalizes Buffers after `structuredClone`. |
|
||||
| `handleUpdateLight(data)` | Merges light into cached column via `applyUpdateLight`. |
|
||||
| `handleUnloadChunk(data)` | Evicts chunk records from cache. |
|
||||
| `handleBlockChange(data)` | Merges single block changes into the cached chunk column. |
|
||||
| `handleMultiBlockChange(data)` | Merges section block updates into the cached chunk column. |
|
||||
| `_buildChunkEntry(chunkData)` | Assembles raw data, block edits, and light arrays. |
|
||||
| `getChunksForReplay(centerChunkX, centerChunkZ, viewDistance)` | Returns cached chunks sorting closest-first. |
|
||||
| `handleBlockChange(data)` | Merges single block into column; logs merge failures. |
|
||||
| `handleMultiBlockChange(data)` | Merges section block records into column. |
|
||||
| `_ensureColumn(stored)` | Lazy-loads prismarine column from `map_chunk` packet data. |
|
||||
| `_syncPacketFromColumn(stored)` | Re-exports merged column to `packetData`. |
|
||||
| `getChunksForReplay(centerChunkX, centerChunkZ, viewDistance)` | Returns in-view chunks only (same distance test as forget), nearest-first. |
|
||||
| `hasChunkAtBlock(x, z)` | Verifies if a chunk is loaded. |
|
||||
| `clear()` | Wipes chunk maps. |
|
||||
|
||||
#### 📄 [chunkMerge.js](file:///home/seb/flayerproxy/src/state/chunkMerge.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `asBuffer(value)` | Coerces `Buffer` / `Uint8Array` for prismarine-chunk / smart-buffer. |
|
||||
| `normalizeMapChunkPacket(packet)` | Fixes binary fields after `structuredClone`. |
|
||||
| `loadColumnFromMapChunk(packet, version, worldBounds)` | Builds prismarine column from `map_chunk`. |
|
||||
| `exportMapChunkPacket(column, packet)` | Dumps column back to `map_chunk` shape. |
|
||||
| `applyBlockChange` / `applyUpdateLight` / `applyMultiBlockChange` | Merge incremental updates into column. |
|
||||
|
||||
#### 🧩 [EntityCache](file:///home/seb/flayerproxy/src/state/EntityCache.js) `class`
|
||||
|
||||
Tracks entities, positions, gear, and status effects.
|
||||
@@ -427,16 +601,17 @@ Caches teams, scores, objectives, and scoreboard layouts.
|
||||
|
||||
---
|
||||
|
||||
### 5. replay — Client Handoff Replay
|
||||
### 6. replay — Client Handoff Replay
|
||||
|
||||
#### 🧩 [StateReplayer](file:///home/seb/flayerproxy/src/replay/StateReplayer.js) `class`
|
||||
|
||||
Replays the cached world state to a connecting client.
|
||||
Replays the cached world state to a connecting play or spectator client.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(worldState, serverConn)` | Initializes replayer with world state and server connection references. |
|
||||
| `replay(client)` | Coordinates sequential packet delivery (see replay sequence below). |
|
||||
| `replaySpectator(client)` | Calls `replay(client, { spectator: true })` — gamemode 3, `camera` lock, no inventory. |
|
||||
| `replay(client, options)` | Sequential packet delivery; `options.spectator` adjusts abilities/gamemode/camera. |
|
||||
|
||||
**`replay()` sequence:**
|
||||
|
||||
@@ -459,7 +634,7 @@ Replays the cached world state to a connecting client.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `replayChunks(write, writeRaw, chunks, center, totalCached)` | Loops through chunk arrays, sending map_chunk buffers (block/light edits already merged), and issues a final `chunk_batch_finished` packet. |
|
||||
| `replayChunks(write, writeRaw, chunks, center, totalCached)` | Loops through chunk arrays, sending raw chunk/light buffers and block overlay edits, and issues a final `chunk_batch_finished` packet. |
|
||||
|
||||
#### 📄 [replayHelpers.js](file:///home/seb/flayerproxy/src/replay/replayHelpers.js) `functions`
|
||||
|
||||
@@ -471,9 +646,11 @@ Replays the cached world state to a connecting client.
|
||||
| `splitMiscReplayPackets(packets)` | Splits misc packets into early configurations, level coordinates, and weather variables. |
|
||||
| `waitForClientTeleportConfirm(client)` | Awaits the client's `teleport_confirm` packet. |
|
||||
|
||||
**Spectator replay differences:** Sends `game_state_change` (gamemode 3), `camera` (bot entity id), flying ability flags; skips full inventory and permission `entity_status`.
|
||||
|
||||
---
|
||||
|
||||
### 6. utils — Helper Utilities
|
||||
### 7. utils — Helper Utilities
|
||||
|
||||
#### 📄 [angles.js](file:///home/seb/flayerproxy/src/utils/angles.js) `functions`
|
||||
|
||||
@@ -528,7 +705,21 @@ Replays the cached world state to a connecting client.
|
||||
|
||||
---
|
||||
|
||||
### 7. sniffer — MITM Packet Sniffer
|
||||
### 8. constants — Shared Packet Sets
|
||||
|
||||
#### 📄 [rawPackets.js](file:///home/seb/flayerproxy/src/constants/rawPackets.js)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `RAW_FORWARD_PACKETS` | Play packets forwarded with `writeRaw` (chunks, lights, view position, batch markers). Used by [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js) and [SpectatorHub](file:///home/seb/flayerproxy/src/spectator/SpectatorHub.js). |
|
||||
|
||||
#### 📄 [spectatorPackets.js](file:///home/seb/flayerproxy/src/constants/spectatorPackets.js)
|
||||
|
||||
See [spectator — Watch-Only Multi-Client Proxy](#4-spectator--watch-only-multi-client-proxy).
|
||||
|
||||
---
|
||||
|
||||
### 9. sniffer — MITM Packet Sniffer
|
||||
|
||||
#### 📄 [src/sniffer/index.js](file:///home/seb/flayerproxy/src/sniffer/index.js)
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"onlineMode": true,
|
||||
"maxClients": 1
|
||||
},
|
||||
"spectator": {
|
||||
"enabled": true,
|
||||
"port": 25568,
|
||||
"onlineMode": true,
|
||||
"maxClients": 20
|
||||
},
|
||||
"sniffer": {
|
||||
"port": 25567,
|
||||
"onlineMode": false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
src/constants/spectatorPackets.js
Normal file
47
src/constants/spectatorPackets.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/** 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',
|
||||
]);
|
||||
|
||||
/**
|
||||
* S2C packets not forwarded to spectators.
|
||||
* tracked_waypoint (journeys/locator bar) is session-ordered (track → update);
|
||||
* mid-join spectators only see updates and disconnect.
|
||||
*/
|
||||
const SPECTATOR_BLOCKED_S2C = new Set(['tracked_waypoint']);
|
||||
|
||||
/** 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_BLOCKED_S2C,
|
||||
SPECTATOR_MOVEMENT_C2S,
|
||||
ANIMATION_SWING_MAIN_HAND,
|
||||
ANIMATION_SWING_OFF_HAND,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -6,14 +6,28 @@ const { disableInboundChatValidation } = require('../utils/chatRelay');
|
||||
const log = createLogger('ProxyServer');
|
||||
|
||||
class ProxyServer {
|
||||
constructor(config, onClientConnect, worldState) {
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {(client: object) => void} onClientConnect
|
||||
* @param {import('../state/WorldStateCache').WorldStateCache} worldState
|
||||
* @param {() => { ok: boolean, reason?: string }} [canAcceptClient]
|
||||
*/
|
||||
constructor(config, onClientConnect, worldState, canAcceptClient) {
|
||||
this.config = config;
|
||||
this.onClientConnect = onClientConnect;
|
||||
this.worldState = worldState;
|
||||
this.canAcceptClient = canAcceptClient;
|
||||
this.server = null;
|
||||
this.activeClient = null;
|
||||
}
|
||||
|
||||
/** Clear the single-client slot if it belongs to this connection. */
|
||||
releaseClient(client) {
|
||||
if (this.activeClient === client) {
|
||||
this.activeClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.server = mc.createServer({
|
||||
host: this.config.proxy.host || '0.0.0.0',
|
||||
@@ -31,8 +45,26 @@ class ProxyServer {
|
||||
},
|
||||
});
|
||||
|
||||
// Replay upstream server's raw config packets before minecraft-protocol's parsed registry.
|
||||
this.server.on('login', (client) => {
|
||||
wrapClientEnd(client);
|
||||
|
||||
const slot = this.canAcceptClient?.() ?? {
|
||||
ok: !this.activeClient,
|
||||
reason: 'Only one client can connect at a time.',
|
||||
};
|
||||
if (!slot.ok || this.activeClient) {
|
||||
const reason = slot.reason || 'Only one client can connect at a time.';
|
||||
log.warn(`Rejecting login for ${client.username || 'client'}: ${reason}`);
|
||||
client.end(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeClient = client;
|
||||
client.on('end', () => {
|
||||
log.info(`Client disconnected: ${client.username}`);
|
||||
this.releaseClient(client);
|
||||
});
|
||||
|
||||
client.prependOnceListener('login_acknowledged', () => {
|
||||
const packets = this.worldState.getRawConfigPacketsForReplay();
|
||||
if (packets.length === 0) return;
|
||||
@@ -49,24 +81,15 @@ class ProxyServer {
|
||||
});
|
||||
|
||||
this.server.on('playerJoin', (client) => {
|
||||
wrapClientEnd(client);
|
||||
disableInboundChatValidation(client);
|
||||
log.info(`Client ready: ${client.username}`);
|
||||
|
||||
if (this.activeClient) {
|
||||
client.end('Another client is already connected.');
|
||||
if (this.activeClient !== client) {
|
||||
log.warn(`Rejecting playerJoin for ${client.username} — not the active client`);
|
||||
client.end('Only one client can connect at a time.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeClient = client;
|
||||
|
||||
client.on('end', () => {
|
||||
log.info(`Client disconnected: ${client.username}`);
|
||||
if (this.activeClient === client) {
|
||||
this.activeClient = null;
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`Client ready: ${client.username}`);
|
||||
this.onClientConnect(client);
|
||||
});
|
||||
|
||||
|
||||
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 { 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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
@@ -37,9 +39,27 @@ class SessionManager {
|
||||
// Core components
|
||||
this.worldState = new WorldStateCache(config);
|
||||
this.serverConn = new ServerConnection(config, this.worldState);
|
||||
this.proxyServer = new ProxyServer(config, (client) => this._onClientConnect(client), this.worldState);
|
||||
this.proxyServer = new ProxyServer(
|
||||
config,
|
||||
(client) => this._onClientConnect(client),
|
||||
this.worldState,
|
||||
() => this._clientSlotStatus(),
|
||||
);
|
||||
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;
|
||||
@@ -54,6 +74,9 @@ class SessionManager {
|
||||
log.info('Starting FlayerProxy...');
|
||||
this.serverConn.connect();
|
||||
this.proxyServer.start();
|
||||
if (this.spectatorProxy) {
|
||||
this.spectatorProxy.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,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');
|
||||
}
|
||||
@@ -111,6 +136,7 @@ class SessionManager {
|
||||
}
|
||||
|
||||
this._cleanupClient();
|
||||
this.spectatorHub?.kickAll(`Server disconnected: ${disconnectReasonText(reason)}`);
|
||||
this._transitionTo(State.INIT);
|
||||
this._scheduleReconnect(5);
|
||||
});
|
||||
@@ -125,6 +151,7 @@ class SessionManager {
|
||||
}
|
||||
|
||||
this._cleanupClient();
|
||||
this.spectatorHub?.kickAll(`Kicked from server: ${disconnectReasonText(reason)}`);
|
||||
this._transitionTo(State.INIT);
|
||||
this._scheduleReconnect(15);
|
||||
});
|
||||
@@ -207,19 +234,74 @@ class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a new Java client may take the single proxy slot.
|
||||
* @returns {{ ok: boolean, reason?: string }}
|
||||
*/
|
||||
_clientSlotStatus() {
|
||||
if (this.currentClient || this.proxyServer.activeClient) {
|
||||
return { ok: false, reason: 'Only one client can connect at a time.' };
|
||||
}
|
||||
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.BOT_MODE) {
|
||||
return { ok: false, reason: 'Another client session is active.' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
_rejectClient(client, reason) {
|
||||
log.warn(`Rejecting ${client.username}: ${reason}`);
|
||||
client.end(reason);
|
||||
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.
|
||||
*/
|
||||
async _onClientConnect(client) {
|
||||
if (this.state === State.INIT) {
|
||||
log.warn('Client connected but bot is not ready yet — rejecting');
|
||||
client.end('Proxy is still connecting to the server. Try again in a moment.');
|
||||
if (this.proxyServer.activeClient !== client) {
|
||||
this._rejectClient(client, 'Only one client can connect at a time.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state === State.HANDOFF || this.state === State.CLIENT_MODE) {
|
||||
log.warn('Client connected but another client is active — rejecting');
|
||||
client.end('Another client session is active.');
|
||||
if (this.state !== State.BOT_MODE || this.currentClient) {
|
||||
this._rejectClient(client, 'Another client session is active.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -279,8 +361,10 @@ class SessionManager {
|
||||
this.clientBridge.stop();
|
||||
this.clientBridge = null;
|
||||
}
|
||||
if (this.currentClient) {
|
||||
this.proxyServer.releaseClient(this.currentClient);
|
||||
}
|
||||
this.currentClient = null;
|
||||
this.proxyServer.activeClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,6 +392,8 @@ class SessionManager {
|
||||
}
|
||||
log.info('Shutting down FlayerProxy...');
|
||||
this._cleanupClient();
|
||||
this.spectatorHub?.stop();
|
||||
this.spectatorProxy?.stop();
|
||||
this.proxyServer.stop();
|
||||
this.serverConn.disconnect();
|
||||
}
|
||||
|
||||
239
src/spectator/SpectatorHub.js
Normal file
239
src/spectator/SpectatorHub.js
Normal file
@@ -0,0 +1,239 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { RAW_FORWARD_PACKETS } = require('../constants/rawPackets');
|
||||
const {
|
||||
SPECTATOR_ALLOWED_C2S,
|
||||
SPECTATOR_BLOCKED_S2C,
|
||||
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;
|
||||
if (SPECTATOR_BLOCKED_S2C.has(name)) 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