feat(minecraft-hub): init, but bugged
This commit is contained in:
Generated
+70
-51
@@ -3153,25 +3153,25 @@ importers:
|
||||
version: 3.102.3
|
||||
mineflayer:
|
||||
specifier: ^4.33.0
|
||||
version: 4.33.0(encoding@0.1.13)
|
||||
version: 4.33.0
|
||||
mineflayer-armor-manager:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(mineflayer@4.33.0(encoding@0.1.13))
|
||||
version: 2.0.1(mineflayer@4.33.0)
|
||||
mineflayer-auto-eat:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3(encoding@0.1.13)
|
||||
version: 5.0.3
|
||||
mineflayer-collectblock:
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0(encoding@0.1.13)
|
||||
version: 1.6.0(prismarine-registry@1.11.0)
|
||||
mineflayer-pathfinder:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
version: 2.4.5(prismarine-registry@1.11.0)
|
||||
mineflayer-pvp:
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0)
|
||||
version: 1.3.2(prismarine-registry@1.11.0)
|
||||
mineflayer-tool:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(encoding@0.1.13)
|
||||
version: 1.2.0(prismarine-registry@1.11.0)
|
||||
nanoid:
|
||||
specifier: 'catalog:'
|
||||
version: 5.1.6
|
||||
@@ -3180,7 +3180,7 @@ importers:
|
||||
version: 0.2.1(zod-to-json-schema@3.25.1(zod@4.3.5))(zod@4.3.5)
|
||||
prismarine-block:
|
||||
specifier: ^1.22.0
|
||||
version: 1.22.0
|
||||
version: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-entity:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
@@ -3192,7 +3192,7 @@ importers:
|
||||
version: 1.3.1(prismarine-registry@1.11.0)
|
||||
prismarine-viewer:
|
||||
specifier: ^1.33.0
|
||||
version: 1.33.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
|
||||
version: 1.33.0(bufferutil@4.1.0)(prismarine-registry@1.11.0)(utf-8-validate@5.0.10)
|
||||
prismarine-windows:
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0
|
||||
@@ -3219,6 +3219,18 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 8.18.1
|
||||
|
||||
services/minecraft-hub:
|
||||
dependencies:
|
||||
'@guiiai/logg':
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.11
|
||||
minecraft-protocol:
|
||||
specifier: ^1.62.0
|
||||
version: 1.62.0(encoding@0.1.13)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
||||
services/satori-bot:
|
||||
dependencies:
|
||||
'@guiiai/logg':
|
||||
@@ -27903,61 +27915,65 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
mineflayer-armor-manager@2.0.1(mineflayer@4.33.0(encoding@0.1.13)):
|
||||
mineflayer-armor-manager@2.0.1(mineflayer@4.33.0):
|
||||
dependencies:
|
||||
minecraft-data: 3.102.3
|
||||
mineflayer: 4.33.0(encoding@0.1.13)
|
||||
mineflayer: 4.33.0
|
||||
|
||||
mineflayer-auto-eat@5.0.3(encoding@0.1.13):
|
||||
mineflayer-auto-eat@5.0.3:
|
||||
dependencies:
|
||||
'@nxg-org/mineflayer-util-plugin': 1.8.4
|
||||
mineflayer: 4.33.0(encoding@0.1.13)
|
||||
mineflayer: 4.33.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
mineflayer-collectblock@1.6.0(encoding@0.1.13):
|
||||
mineflayer-collectblock@1.6.0(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
mineflayer: 4.33.0(encoding@0.1.13)
|
||||
mineflayer-pathfinder: 2.4.5
|
||||
mineflayer-tool: 1.2.0(encoding@0.1.13)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
mineflayer-pathfinder@2.4.5:
|
||||
dependencies:
|
||||
minecraft-data: 3.102.3
|
||||
prismarine-block: 1.22.0
|
||||
prismarine-entity: 2.5.0
|
||||
prismarine-item: 1.17.0
|
||||
prismarine-nbt: 2.8.0
|
||||
prismarine-physics: 1.10.0
|
||||
vec3: 0.1.10
|
||||
|
||||
mineflayer-pvp@1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
mineflayer: 4.33.0(encoding@0.1.13)
|
||||
mineflayer-pathfinder: 2.4.5
|
||||
mineflayer-utils: 0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0)
|
||||
mineflayer: 4.33.0
|
||||
mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0)
|
||||
mineflayer-tool: 1.2.0(prismarine-registry@1.11.0)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- prismarine-registry
|
||||
- supports-color
|
||||
|
||||
mineflayer-tool@1.2.0(encoding@0.1.13):
|
||||
mineflayer-pathfinder@2.4.5(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
mineflayer: 4.33.0(encoding@0.1.13)
|
||||
mineflayer-pathfinder: 2.4.5
|
||||
minecraft-data: 3.102.3
|
||||
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-entity: 2.5.0
|
||||
prismarine-item: 1.17.0
|
||||
prismarine-nbt: 2.8.0
|
||||
prismarine-physics: 1.10.0
|
||||
vec3: 0.1.10
|
||||
transitivePeerDependencies:
|
||||
- prismarine-registry
|
||||
|
||||
mineflayer-pvp@1.3.2(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
mineflayer: 4.33.0
|
||||
mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0)
|
||||
mineflayer-utils: 0.1.4(prismarine-registry@1.11.0)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- prismarine-registry
|
||||
- supports-color
|
||||
|
||||
mineflayer-tool@1.2.0(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
mineflayer: 4.33.0
|
||||
mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0)
|
||||
prismarine-nbt: 2.8.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- prismarine-registry
|
||||
- supports-color
|
||||
|
||||
mineflayer-utils@0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0):
|
||||
mineflayer-utils@0.1.4(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
'@types/node': 14.18.63
|
||||
mineflayer: 2.41.0(encoding@0.1.13)(prismarine-registry@1.11.0)
|
||||
mineflayer: 2.41.0(prismarine-registry@1.11.0)
|
||||
prismarine-entity: 1.2.0
|
||||
require-self: 0.2.3
|
||||
typescript: 3.9.10
|
||||
@@ -27966,12 +27982,12 @@ snapshots:
|
||||
- prismarine-registry
|
||||
- supports-color
|
||||
|
||||
mineflayer@2.41.0(encoding@0.1.13)(prismarine-registry@1.11.0):
|
||||
mineflayer@2.41.0(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
minecraft-data: 2.221.0
|
||||
minecraft-protocol: 1.62.0(encoding@0.1.13)
|
||||
prismarine-biome: 1.3.0(minecraft-data@2.221.0)(prismarine-registry@1.11.0)
|
||||
prismarine-block: 1.22.0
|
||||
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-chat: 1.12.0
|
||||
prismarine-chunk: 1.39.0(minecraft-data@2.221.0)
|
||||
prismarine-entity: 1.2.0
|
||||
@@ -27988,12 +28004,12 @@ snapshots:
|
||||
- prismarine-registry
|
||||
- supports-color
|
||||
|
||||
mineflayer@4.33.0(encoding@0.1.13):
|
||||
mineflayer@4.33.0:
|
||||
dependencies:
|
||||
minecraft-data: 3.102.3
|
||||
minecraft-protocol: 1.62.0(encoding@0.1.13)
|
||||
prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0)
|
||||
prismarine-block: 1.22.0
|
||||
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-chat: 1.12.0
|
||||
prismarine-chunk: 1.39.0(minecraft-data@3.102.3)
|
||||
prismarine-entity: 2.5.0
|
||||
@@ -29055,7 +29071,7 @@ snapshots:
|
||||
minecraft-data: 3.102.3
|
||||
prismarine-registry: 1.11.0
|
||||
|
||||
prismarine-block@1.22.0:
|
||||
prismarine-block@1.22.0(prismarine-registry@1.11.0):
|
||||
dependencies:
|
||||
minecraft-data: 3.102.3
|
||||
prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0)
|
||||
@@ -29063,6 +29079,8 @@ snapshots:
|
||||
prismarine-item: 1.17.0
|
||||
prismarine-nbt: 2.8.0
|
||||
prismarine-registry: 1.11.0
|
||||
transitivePeerDependencies:
|
||||
- prismarine-registry
|
||||
|
||||
prismarine-chat@1.12.0:
|
||||
dependencies:
|
||||
@@ -29073,7 +29091,7 @@ snapshots:
|
||||
prismarine-chunk@1.39.0(minecraft-data@2.221.0):
|
||||
dependencies:
|
||||
prismarine-biome: 1.3.0(minecraft-data@2.221.0)(prismarine-registry@1.11.0)
|
||||
prismarine-block: 1.22.0
|
||||
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-nbt: 2.8.0
|
||||
prismarine-registry: 1.11.0
|
||||
smart-buffer: 4.2.0
|
||||
@@ -29086,7 +29104,7 @@ snapshots:
|
||||
prismarine-chunk@1.39.0(minecraft-data@3.102.3):
|
||||
dependencies:
|
||||
prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0)
|
||||
prismarine-block: 1.22.0
|
||||
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-nbt: 2.8.0
|
||||
prismarine-registry: 1.11.0
|
||||
smart-buffer: 4.2.0
|
||||
@@ -29137,16 +29155,16 @@ snapshots:
|
||||
prismarine-registry@1.11.0:
|
||||
dependencies:
|
||||
minecraft-data: 3.102.3
|
||||
prismarine-block: 1.22.0
|
||||
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-nbt: 2.8.0
|
||||
|
||||
prismarine-viewer@1.33.0(bufferutil@4.1.0)(utf-8-validate@5.0.10):
|
||||
prismarine-viewer@1.33.0(bufferutil@4.1.0)(prismarine-registry@1.11.0)(utf-8-validate@5.0.10):
|
||||
dependencies:
|
||||
'@tweenjs/tween.js': 23.1.3
|
||||
compression: 1.8.1
|
||||
express: 4.22.1
|
||||
minecraft-data: 3.102.3
|
||||
prismarine-block: 1.22.0
|
||||
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
|
||||
prismarine-chunk: 1.39.0(minecraft-data@3.102.3)
|
||||
prismarine-world: 3.6.3
|
||||
socket.io: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)
|
||||
@@ -29156,6 +29174,7 @@ snapshots:
|
||||
vec3: 0.1.10
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- prismarine-registry
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Hub owns the single upstream session
|
||||
HUB_SINGLE_TARGET=true
|
||||
HUB_VERSION=1.21.8
|
||||
HUB_MOTD=AIRI Minecraft Hub
|
||||
|
||||
# Viewer listener (online-mode recommended for skins)
|
||||
HUB_VIEWER_LISTEN_HOST=0.0.0.0
|
||||
HUB_VIEWER_LISTEN_PORT=25566
|
||||
HUB_VIEWER_ONLINE_MODE=true
|
||||
HUB_VIEWER_USERNAME=airi-bot
|
||||
|
||||
# Bot listener (offline)
|
||||
HUB_BOT_LISTEN_HOST=0.0.0.0
|
||||
HUB_BOT_LISTEN_PORT=25567
|
||||
HUB_BOT_ONLINE_MODE=false
|
||||
HUB_BOT_USERNAME=airi-bot-mineflayer
|
||||
|
||||
# Target server (real Minecraft server)
|
||||
HUB_UPSTREAM_HOST=localhost
|
||||
HUB_UPSTREAM_PORT=25565
|
||||
HUB_UPSTREAM_AUTH=microsoft
|
||||
HUB_UPSTREAM_USERNAME=airi-bot
|
||||
|
||||
HUB_REWRITE_IDENTITY=false
|
||||
HUB_DEBUG_PACKETS=false
|
||||
HUB_DUMP_PACKETS=true
|
||||
HUB_DUMP_DIR=./packet-dumps
|
||||
|
||||
# Mirroring and input filters
|
||||
HUB_MIRROR_MOVEMENT=true
|
||||
HUB_MIRROR_ACTIONS=false
|
||||
HUB_BLOCK_VIEWER_MOVEMENT=true
|
||||
HUB_BLOCK_VIEWER_ACTIONS=true
|
||||
@@ -0,0 +1,47 @@
|
||||
# Minecraft Hub (Protocol Relay)
|
||||
|
||||
Relay service that accepts Minecraft clients (bot + viewer) and proxies them to a target Minecraft server. The hub owns a single upstream authenticated session, and both clients are downstream-only.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/minecraft-hub dev
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Core:
|
||||
- `HUB_VERSION` (default: `1.20`)
|
||||
- `HUB_MOTD` (default: `AIRI Minecraft Hub`)
|
||||
|
||||
Viewer listener (debug client):
|
||||
- `HUB_VIEWER_LISTEN_HOST` (default: `0.0.0.0`)
|
||||
- `HUB_VIEWER_LISTEN_PORT` (default: `25566`)
|
||||
- `HUB_VIEWER_ONLINE_MODE` (default: `true`)
|
||||
- `HUB_VIEWER_USERNAME` (optional, defaults to `HUB_UPSTREAM_USERNAME`)
|
||||
|
||||
Bot listener (mineflayer):
|
||||
- `HUB_BOT_LISTEN_HOST` (default: `0.0.0.0`)
|
||||
- `HUB_BOT_LISTEN_PORT` (default: `25567`)
|
||||
- `HUB_BOT_ONLINE_MODE` (default: `false`)
|
||||
- `HUB_BOT_USERNAME` (default: `airi-bot-mineflayer`)
|
||||
|
||||
Target server (real Minecraft server):
|
||||
- `HUB_UPSTREAM_HOST` (default: `localhost`)
|
||||
- `HUB_UPSTREAM_PORT` (default: `25565`)
|
||||
- `HUB_UPSTREAM_AUTH` (default: `offline`) — `offline` | `mojang` | `microsoft`
|
||||
- `HUB_UPSTREAM_USERNAME` (default: `airi-bot`)
|
||||
- `HUB_REWRITE_IDENTITY` (default: `false`) — when enabled, rewrites player identity packets for downstream clients
|
||||
- `HUB_DEBUG_PACKETS` (default: `false`) — log raw/decoded packet flow for debugging
|
||||
- `HUB_DUMP_PACKETS` (default: `false`) — write raw packet hex dumps to disk
|
||||
- `HUB_DUMP_DIR` (default: `./packet-dumps`) — dump directory
|
||||
|
||||
Mirroring / input filters:
|
||||
- `HUB_MIRROR_MOVEMENT` (default: `true`)
|
||||
- `HUB_MIRROR_ACTIONS` (default: `false`)
|
||||
|
||||
## Notes
|
||||
|
||||
- The hub connects to the target server once and broadcasts server packets to both downstream clients.
|
||||
- The viewer is input-blocked and acts as a debug view while the bot controls the upstream session.
|
||||
- The hub rewrites UUIDs in key packets (player info/spawn) so downstream clients can keep their own local identities while the hub owns the upstream account.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@proj-airi/minecraft-hub",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Minecraft protocol relay hub for AIRI",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "services/minecraft-hub"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts",
|
||||
"dev": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@guiiai/logg": "catalog:",
|
||||
"minecraft-protocol": "^1.62.0",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { env } from 'node:process'
|
||||
|
||||
export type AuthType = 'offline' | 'mojang' | 'microsoft'
|
||||
|
||||
export interface HubConfig {
|
||||
version: string
|
||||
motd: string
|
||||
|
||||
viewerListenHost: string
|
||||
viewerListenPort: number
|
||||
viewerOnlineMode: boolean
|
||||
viewerUsername?: string
|
||||
|
||||
botListenHost: string
|
||||
botListenPort: number
|
||||
botOnlineMode: boolean
|
||||
botUsername: string
|
||||
|
||||
upstreamHost: string
|
||||
upstreamPort: number
|
||||
upstreamAuth: AuthType
|
||||
upstreamUsername: string
|
||||
|
||||
rewriteIdentity: boolean
|
||||
debugPackets: boolean
|
||||
dumpPackets: boolean
|
||||
dumpDir: string
|
||||
|
||||
mirrorMovement: boolean
|
||||
mirrorActions: boolean
|
||||
}
|
||||
|
||||
function getEnvVar<V extends string>(key: string, fallback: V): V {
|
||||
return (env[key] || fallback) as V
|
||||
}
|
||||
|
||||
function getEnvNumber(key: string, fallback: number): number {
|
||||
return Number.parseInt(env[key] || String(fallback))
|
||||
}
|
||||
|
||||
function getEnvBool(key: string, fallback: boolean): boolean {
|
||||
const value = env[key]
|
||||
if (value === undefined)
|
||||
return fallback
|
||||
return value.toLowerCase() === 'true' || value === '1'
|
||||
}
|
||||
|
||||
export function loadConfig(): HubConfig {
|
||||
return {
|
||||
version: getEnvVar('HUB_VERSION', '1.20'),
|
||||
motd: getEnvVar('HUB_MOTD', 'AIRI Minecraft Hub'),
|
||||
|
||||
viewerListenHost: getEnvVar('HUB_VIEWER_LISTEN_HOST', '0.0.0.0'),
|
||||
viewerListenPort: getEnvNumber('HUB_VIEWER_LISTEN_PORT', 25566),
|
||||
viewerOnlineMode: getEnvBool('HUB_VIEWER_ONLINE_MODE', true),
|
||||
viewerUsername: env.HUB_VIEWER_USERNAME,
|
||||
|
||||
botListenHost: getEnvVar('HUB_BOT_LISTEN_HOST', '0.0.0.0'),
|
||||
botListenPort: getEnvNumber('HUB_BOT_LISTEN_PORT', 25567),
|
||||
botOnlineMode: getEnvBool('HUB_BOT_ONLINE_MODE', false),
|
||||
botUsername: getEnvVar('HUB_BOT_USERNAME', 'airi-bot-mineflayer'),
|
||||
|
||||
upstreamHost: getEnvVar('HUB_UPSTREAM_HOST', 'localhost'),
|
||||
upstreamPort: getEnvNumber('HUB_UPSTREAM_PORT', 25565),
|
||||
upstreamAuth: getEnvVar('HUB_UPSTREAM_AUTH', 'offline') as AuthType,
|
||||
upstreamUsername: getEnvVar('HUB_UPSTREAM_USERNAME', 'airi-bot'),
|
||||
|
||||
rewriteIdentity: getEnvBool('HUB_REWRITE_IDENTITY', false),
|
||||
debugPackets: getEnvBool('HUB_DEBUG_PACKETS', false),
|
||||
dumpPackets: getEnvBool('HUB_DUMP_PACKETS', false),
|
||||
dumpDir: getEnvVar('HUB_DUMP_DIR', './packet-dumps'),
|
||||
|
||||
mirrorMovement: getEnvBool('HUB_MIRROR_MOVEMENT', true),
|
||||
mirrorActions: getEnvBool('HUB_MIRROR_ACTIONS', false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
|
||||
import type { HubConfig } from './config'
|
||||
|
||||
import { createWriteStream, mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { createClient, createServer } from 'minecraft-protocol'
|
||||
|
||||
import { useLogger } from './logger'
|
||||
|
||||
type ProtocolClient = any
|
||||
|
||||
type HubRole = 'bot' | 'viewer'
|
||||
|
||||
interface HubSession {
|
||||
role: HubRole
|
||||
username: string
|
||||
downstream: ProtocolClient
|
||||
readyForPlay: boolean
|
||||
}
|
||||
|
||||
const MOVEMENT_PACKETS = new Set([
|
||||
'position',
|
||||
'position_look',
|
||||
'look',
|
||||
'move',
|
||||
'vehicle_move',
|
||||
])
|
||||
|
||||
const ACTION_PACKETS = new Set([
|
||||
'arm_animation',
|
||||
'block_dig',
|
||||
'block_place',
|
||||
'use_item',
|
||||
'use_entity',
|
||||
'held_item_slot',
|
||||
'player_command',
|
||||
'entity_action',
|
||||
])
|
||||
|
||||
export interface HubHandle {
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
function rewriteUuidArray(uuids: string[], targetUuid: string, replacementUuid: string) {
|
||||
return uuids.map(uuid => (uuid === targetUuid ? replacementUuid : uuid))
|
||||
}
|
||||
|
||||
function rewritePacketForSession(name: string, data: any, session: HubSession, targetUsername?: string, targetUuid?: string) {
|
||||
if (!targetUuid || !session.downstream?.uuid)
|
||||
return data
|
||||
|
||||
const replacementUuid = session.downstream.uuid
|
||||
const replacementName = session.username
|
||||
|
||||
if (targetUuid === replacementUuid && (!targetUsername || targetUsername === replacementName))
|
||||
return data
|
||||
|
||||
if (name === 'player_info' && Array.isArray(data.data)) {
|
||||
return {
|
||||
...data,
|
||||
data: data.data.map((entry: any) => {
|
||||
if (entry.uuid !== targetUuid)
|
||||
return entry
|
||||
return {
|
||||
...entry,
|
||||
uuid: replacementUuid,
|
||||
name: targetUsername && entry.name === targetUsername ? replacementName : entry.name,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'player_info_update' && Array.isArray(data.data)) {
|
||||
return {
|
||||
...data,
|
||||
data: data.data.map((entry: any) => {
|
||||
if (entry.uuid !== targetUuid)
|
||||
return entry
|
||||
return {
|
||||
...entry,
|
||||
uuid: replacementUuid,
|
||||
name: targetUsername && entry.name === targetUsername ? replacementName : entry.name,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'player_remove') {
|
||||
if (Array.isArray(data.players))
|
||||
return { ...data, players: rewriteUuidArray(data.players, targetUuid, replacementUuid) }
|
||||
if (Array.isArray(data.uuids))
|
||||
return { ...data, uuids: rewriteUuidArray(data.uuids, targetUuid, replacementUuid) }
|
||||
}
|
||||
|
||||
if ((name === 'named_entity_spawn' || name === 'spawn_player') && data.playerUUID) {
|
||||
if (data.playerUUID !== targetUuid)
|
||||
return data
|
||||
return { ...data, playerUUID: replacementUuid }
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && data !== null && data.uuid === targetUuid)
|
||||
return { ...data, uuid: replacementUuid }
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function startHub(config: HubConfig): Promise<HubHandle> {
|
||||
const logger = useLogger('minecraft-hub')
|
||||
|
||||
const sessions = new Set<HubSession>()
|
||||
const configPacketBuffer: Array<{ buffer: Buffer, name: string, compressionThreshold?: number }> = []
|
||||
const playPacketBuffer: Array<{ name: string, data: any }> = []
|
||||
let configCompleted = false
|
||||
let configCompressionThreshold: number | null = null
|
||||
let initialDispatchDone = false
|
||||
let rawDumpStream: NodeJS.WritableStream | null = null
|
||||
|
||||
if (config.dumpPackets) {
|
||||
mkdirSync(config.dumpDir, { recursive: true })
|
||||
const filename = `hub-${new Date().toISOString().replace(/[:.]/g, '-')}.log`
|
||||
const path = join(config.dumpDir, filename)
|
||||
rawDumpStream = createWriteStream(path, { flags: 'a' })
|
||||
logger.withFields({ path }).log('Packet dump enabled')
|
||||
}
|
||||
|
||||
const dumpRaw = (direction: string, meta: { name: string, state: string }, buffer: Buffer) => {
|
||||
if (!rawDumpStream)
|
||||
return
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
direction,
|
||||
state: meta.state,
|
||||
name: meta.name,
|
||||
size: buffer.length,
|
||||
hex: buffer.toString('hex'),
|
||||
})
|
||||
rawDumpStream.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const mirrorToViewers = (name: string, data: unknown) => {
|
||||
for (const session of sessions) {
|
||||
if (session.role !== 'viewer')
|
||||
continue
|
||||
if (!session.readyForPlay)
|
||||
continue
|
||||
try {
|
||||
session.downstream.write(name, data as never)
|
||||
}
|
||||
catch (error) {
|
||||
logger.withFields({ packet: name, viewer: session.username }).errorWithError('Failed to mirror packet to viewer', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasRole = (role: HubRole) => {
|
||||
for (const session of sessions) {
|
||||
if (session.role === role)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const canDispatchInitialPackets = () => configCompleted && hasRole('viewer') && hasRole('bot') && !initialDispatchDone
|
||||
|
||||
const dispatchInitialPackets = () => {
|
||||
if (!canDispatchInitialPackets())
|
||||
return
|
||||
|
||||
for (const session of sessions) {
|
||||
session.downstream.state = 'configuration'
|
||||
for (const packet of configPacketBuffer) {
|
||||
session.downstream.writeRaw(packet.buffer)
|
||||
if (packet.name === 'set_compression' && typeof packet.compressionThreshold === 'number')
|
||||
session.downstream.compressionThreshold = packet.compressionThreshold
|
||||
if (config.dumpPackets)
|
||||
dumpRaw(`hub->downstream:${session.role}`, { name: packet.name, state: 'configuration' }, packet.buffer)
|
||||
}
|
||||
session.downstream.state = 'play'
|
||||
session.readyForPlay = true
|
||||
}
|
||||
|
||||
for (const packet of playPacketBuffer) {
|
||||
for (const session of sessions) {
|
||||
if (!session.readyForPlay)
|
||||
continue
|
||||
try {
|
||||
const payload = config.rewriteIdentity
|
||||
? rewritePacketForSession(packet.name, packet.data, session, targetIdentity.username, targetIdentity.uuid)
|
||||
: packet.data
|
||||
session.downstream.write(packet.name, payload)
|
||||
}
|
||||
catch (error) {
|
||||
logger.withFields({ packet: packet.name, role: session.role }).errorWithError('Failed to replay packet to downstream', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playPacketBuffer.length = 0
|
||||
initialDispatchDone = true
|
||||
}
|
||||
|
||||
const target = createClient({
|
||||
host: config.upstreamHost,
|
||||
port: config.upstreamPort,
|
||||
username: config.upstreamUsername,
|
||||
auth: config.upstreamAuth === 'offline' ? undefined : config.upstreamAuth,
|
||||
version: config.version,
|
||||
})
|
||||
|
||||
const targetIdentity = {
|
||||
username: config.upstreamUsername,
|
||||
uuid: '',
|
||||
}
|
||||
|
||||
target.on('raw', (buffer: Buffer, meta: { name: string, state: string }) => {
|
||||
if (config.debugPackets)
|
||||
logger.withFields({ direction: 'target->hub', state: meta.state, name: meta.name, size: buffer.length }).log('Raw packet')
|
||||
if (config.dumpPackets)
|
||||
dumpRaw('target->hub', meta, buffer)
|
||||
if (meta.state !== 'configuration' && (meta.state !== 'play' || config.rewriteIdentity))
|
||||
return
|
||||
if (meta.state === 'configuration') {
|
||||
configPacketBuffer.push({ buffer, name: meta.name, compressionThreshold: configCompressionThreshold ?? undefined })
|
||||
}
|
||||
})
|
||||
|
||||
target.on('packet', (data: any, meta: { name: string, state: string }) => {
|
||||
if (config.debugPackets)
|
||||
logger.withFields({ direction: 'target->hub', state: meta.state, name: meta.name }).log('Decoded packet')
|
||||
if (meta.state === 'configuration' && meta.name === 'set_compression' && typeof data.threshold === 'number')
|
||||
configCompressionThreshold = data.threshold
|
||||
|
||||
if (meta.name === 'player_info' && Array.isArray(data.data)) {
|
||||
const hubEntry = data.data.find((entry: any) => entry.name === config.upstreamUsername)
|
||||
if (hubEntry?.uuid)
|
||||
targetIdentity.uuid = hubEntry.uuid
|
||||
}
|
||||
|
||||
if (!configCompleted && meta.state === 'configuration' && meta.name === 'finish_configuration') {
|
||||
configCompleted = true
|
||||
dispatchInitialPackets()
|
||||
}
|
||||
|
||||
if (meta.state === 'play') {
|
||||
if (!initialDispatchDone) {
|
||||
playPacketBuffer.push({ name: meta.name, data })
|
||||
return
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.readyForPlay)
|
||||
continue
|
||||
try {
|
||||
if (config.debugPackets)
|
||||
logger.withFields({ direction: 'hub->downstream', state: meta.state, name: meta.name, role: session.role }).log('Decoded forward')
|
||||
const payload = config.rewriteIdentity
|
||||
? rewritePacketForSession(meta.name, data, session, targetIdentity.username, targetIdentity.uuid)
|
||||
: data
|
||||
session.downstream.write(meta.name, payload)
|
||||
}
|
||||
catch (error) {
|
||||
logger.withFields({ packet: meta.name, role: session.role }).errorWithError('Failed to forward packet to downstream', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
target.on('end', () => {
|
||||
for (const session of sessions) {
|
||||
if (!session.downstream.ended)
|
||||
session.downstream.end('target_end')
|
||||
}
|
||||
})
|
||||
|
||||
target.on('error', error => logger.errorWithError('Target server error', error))
|
||||
|
||||
const createListener = (role: HubRole, host: string, port: number, onlineMode: boolean) => {
|
||||
const server = createServer({
|
||||
host,
|
||||
port,
|
||||
'version': config.version,
|
||||
'motd': config.motd,
|
||||
'maxPlayers': 10,
|
||||
'online-mode': onlineMode,
|
||||
})
|
||||
|
||||
server.on('login', (downstream: ProtocolClient) => {
|
||||
const isViewer = role === 'viewer'
|
||||
const expectedViewer = config.viewerUsername || config.upstreamUsername
|
||||
|
||||
if (isViewer && expectedViewer && downstream.username !== expectedViewer) {
|
||||
logger.withFields({ username: downstream.username }).error('Rejected viewer: username mismatch')
|
||||
downstream.end('viewer_username_mismatch')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isViewer && downstream.username !== config.botUsername) {
|
||||
logger.withFields({ username: downstream.username }).error('Rejected bot: username mismatch')
|
||||
downstream.end('bot_username_mismatch')
|
||||
return
|
||||
}
|
||||
|
||||
const session: HubSession = { role, username: downstream.username, downstream, readyForPlay: false }
|
||||
sessions.add(session)
|
||||
|
||||
logger.withFields({ role, username: downstream.username }).log('Hub client connected')
|
||||
|
||||
downstream.state = 'configuration'
|
||||
if (configCompleted && initialDispatchDone) {
|
||||
for (const packet of configPacketBuffer) {
|
||||
downstream.writeRaw(packet.buffer)
|
||||
if (packet.name === 'set_compression' && typeof packet.compressionThreshold === 'number')
|
||||
downstream.compressionThreshold = packet.compressionThreshold
|
||||
}
|
||||
downstream.state = 'play'
|
||||
session.readyForPlay = true
|
||||
}
|
||||
|
||||
dispatchInitialPackets()
|
||||
|
||||
const relayDownstreamToTarget = (data: any, meta: { name: string, state: string }) => {
|
||||
if (meta.state !== 'play')
|
||||
return
|
||||
|
||||
if (role === 'viewer')
|
||||
return
|
||||
|
||||
try {
|
||||
if (role === 'bot' && (target as any).state === 'play')
|
||||
target.write(meta.name, data)
|
||||
if (config.debugPackets)
|
||||
logger.withFields({ direction: 'downstream->hub', state: meta.state, name: meta.name, role }).log('Decoded upstream send')
|
||||
}
|
||||
catch (error) {
|
||||
logger.withFields({ packet: meta.name, role }).errorWithError('Failed to forward packet to target server', error as Error)
|
||||
}
|
||||
|
||||
if (role === 'bot' && config.mirrorMovement && MOVEMENT_PACKETS.has(meta.name))
|
||||
mirrorToViewers(meta.name, data)
|
||||
|
||||
if (role === 'bot' && config.mirrorActions && ACTION_PACKETS.has(meta.name))
|
||||
mirrorToViewers(meta.name, data)
|
||||
}
|
||||
|
||||
if (!config.rewriteIdentity && role === 'bot') {
|
||||
downstream.on('raw', (buffer: Buffer, meta: { name: string, state: string }) => {
|
||||
if (meta.state !== 'play')
|
||||
return
|
||||
if ((target as any).state === 'play')
|
||||
target.writeRaw(buffer)
|
||||
if (config.debugPackets)
|
||||
logger.withFields({ direction: 'downstream->hub', state: meta.state, name: meta.name, role, size: buffer.length }).log('Raw upstream send')
|
||||
if (config.dumpPackets)
|
||||
dumpRaw('downstream->hub', meta, buffer)
|
||||
})
|
||||
}
|
||||
else {
|
||||
downstream.on('packet', relayDownstreamToTarget)
|
||||
}
|
||||
|
||||
const cleanup = (reason: string) => {
|
||||
sessions.delete(session)
|
||||
logger.withFields({ role, username: downstream.username, reason }).log('Closing hub session')
|
||||
if (!downstream.ended)
|
||||
downstream.end(reason)
|
||||
}
|
||||
|
||||
downstream.on('end', () => cleanup('downstream_end'))
|
||||
downstream.on('error', error => logger.errorWithError('Downstream error', error))
|
||||
})
|
||||
|
||||
server.on('error', error => logger.errorWithError('Hub server error', error as Error))
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
const viewerServer = createListener('viewer', config.viewerListenHost, config.viewerListenPort, config.viewerOnlineMode)
|
||||
const botServer = createListener('bot', config.botListenHost, config.botListenPort, config.botOnlineMode)
|
||||
|
||||
logger.withFields({
|
||||
version: config.version,
|
||||
viewerListenHost: config.viewerListenHost,
|
||||
viewerListenPort: config.viewerListenPort,
|
||||
viewerOnlineMode: config.viewerOnlineMode,
|
||||
botListenHost: config.botListenHost,
|
||||
botListenPort: config.botListenPort,
|
||||
upstreamHost: config.upstreamHost,
|
||||
upstreamPort: config.upstreamPort,
|
||||
}).log('Minecraft hub started')
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
for (const session of sessions) {
|
||||
if (!session.downstream.ended)
|
||||
session.downstream.end('hub_shutdown')
|
||||
if (session.target && !session.target.ended)
|
||||
session.target.end('hub_shutdown')
|
||||
}
|
||||
|
||||
if (!target.ended)
|
||||
target.end('hub_shutdown')
|
||||
|
||||
viewerServer.close()
|
||||
botServer.close()
|
||||
|
||||
if (rawDumpStream)
|
||||
rawDumpStream.end()
|
||||
|
||||
logger.log('Minecraft hub stopped')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import process, { exit } from 'node:process'
|
||||
|
||||
import { loadConfig } from './config'
|
||||
import { startHub } from './hub'
|
||||
import { initLogger, useLogger } from './logger'
|
||||
|
||||
async function main() {
|
||||
initLogger()
|
||||
const logger = useLogger('minecraft-hub')
|
||||
const config = loadConfig()
|
||||
|
||||
const hub = await startHub(config)
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.log('Received SIGINT, shutting down...')
|
||||
void hub.close()
|
||||
exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((error: Error) => {
|
||||
useLogger('minecraft-hub').errorWithError('Fatal error', error)
|
||||
exit(1)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initLogger() {
|
||||
if (initialized)
|
||||
return
|
||||
|
||||
setGlobalLogLevel(LogLevel.Debug)
|
||||
setGlobalFormat(Format.Pretty)
|
||||
useLogg('minecraft-hub').useGlobalConfig().log('Logger initialized')
|
||||
initialized = true
|
||||
}
|
||||
|
||||
export function useLogger(scope = 'minecraft-hub') {
|
||||
return useLogg(scope).useGlobalConfig()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user