@pilaf/backends 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/BotLifecycleManager.js +242 -0
- package/lib/BotPool.js +202 -0
- package/lib/ConnectionState.js +50 -0
- package/lib/ServerHealthChecker.js +174 -0
- package/lib/backend.js +78 -0
- package/lib/factory.js +30 -0
- package/lib/index.js +23 -0
- package/lib/mineflayer-backend.js +291 -0
- package/lib/rcon-backend.js +210 -0
- package/package.json +20 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BotLifecycleManager - Manages the complete lifecycle of a Mineflayer bot
|
|
3
|
+
* Handles creation, spawning, and disconnection with proper event-driven patterns
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { ConnectionState } = require('./ConnectionState.js');
|
|
7
|
+
|
|
8
|
+
class BotLifecycleManager {
|
|
9
|
+
/**
|
|
10
|
+
* Create a bot and wait for it to spawn
|
|
11
|
+
* @param {Function} createBotFn - Function that creates a Mineflayer bot
|
|
12
|
+
* @param {Object} options - Bot creation options
|
|
13
|
+
* @param {number} [options.spawnTimeout] - Max time to wait for spawn (default: 30000ms)
|
|
14
|
+
* @returns {Promise<{bot: Object, state: string}>} Bot instance and final state
|
|
15
|
+
*/
|
|
16
|
+
static async createAndSpawnBot(createBotFn, options = {}) {
|
|
17
|
+
const { spawnTimeout = 30000 } = options;
|
|
18
|
+
let bot = null;
|
|
19
|
+
let currentState = ConnectionState.CONNECTING;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
// Create the bot
|
|
23
|
+
bot = createBotFn(options);
|
|
24
|
+
currentState = ConnectionState.SPAWNING;
|
|
25
|
+
|
|
26
|
+
// Wait for spawn event with proper error handling
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
const timeoutId = setTimeout(() => {
|
|
29
|
+
// Clean up event listeners
|
|
30
|
+
bot.removeAllListeners('spawn');
|
|
31
|
+
bot.removeAllListeners('error');
|
|
32
|
+
bot.removeAllListeners('kicked');
|
|
33
|
+
bot.removeAllListeners('end');
|
|
34
|
+
reject(new Error(`Bot spawn timeout after ${spawnTimeout}ms`));
|
|
35
|
+
}, spawnTimeout);
|
|
36
|
+
|
|
37
|
+
bot.once('spawn', () => {
|
|
38
|
+
clearTimeout(timeoutId);
|
|
39
|
+
currentState = ConnectionState.SPAWNED;
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
bot.once('error', (err) => {
|
|
44
|
+
clearTimeout(timeoutId);
|
|
45
|
+
currentState = ConnectionState.ERROR;
|
|
46
|
+
bot.removeAllListeners('spawn');
|
|
47
|
+
reject(new Error(`Bot connection error: ${err.message}`));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
bot.once('kicked', (reason) => {
|
|
51
|
+
clearTimeout(timeoutId);
|
|
52
|
+
currentState = ConnectionState.ERROR;
|
|
53
|
+
bot.removeAllListeners('spawn');
|
|
54
|
+
reject(new Error(`Bot kicked by server: ${reason}`));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
bot.once('end', () => {
|
|
58
|
+
if (currentState !== ConnectionState.SPAWNED) {
|
|
59
|
+
clearTimeout(timeoutId);
|
|
60
|
+
currentState = ConnectionState.ERROR;
|
|
61
|
+
bot.removeAllListeners('spawn');
|
|
62
|
+
reject(new Error(`Bot connection ended before spawn`));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { bot, state: currentState };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
currentState = ConnectionState.ERROR;
|
|
70
|
+
// Clean up bot if creation failed
|
|
71
|
+
if (bot) {
|
|
72
|
+
try {
|
|
73
|
+
bot.quit();
|
|
74
|
+
} catch {
|
|
75
|
+
// Ignore cleanup errors
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Quit a bot and wait for disconnection to complete
|
|
84
|
+
* @param {Object} bot - Mineflayer bot instance
|
|
85
|
+
* @param {Object} options - Options
|
|
86
|
+
* @param {number} [options.disconnectTimeout] - Max time to wait (default: 10000ms)
|
|
87
|
+
* @returns {Promise<{success: boolean, reason: string}>}
|
|
88
|
+
*/
|
|
89
|
+
static async quitBot(bot, options = {}) {
|
|
90
|
+
const { disconnectTimeout = 10000 } = options;
|
|
91
|
+
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let resolved = false;
|
|
94
|
+
|
|
95
|
+
// Set up timeout FIRST
|
|
96
|
+
const timeoutId = setTimeout(() => {
|
|
97
|
+
if (!resolved) {
|
|
98
|
+
resolved = true;
|
|
99
|
+
// Force cleanup on timeout
|
|
100
|
+
this._forceCleanup(bot);
|
|
101
|
+
resolve({ success: false, reason: 'Disconnect timeout' });
|
|
102
|
+
}
|
|
103
|
+
}, disconnectTimeout);
|
|
104
|
+
|
|
105
|
+
// Set up event listeners BEFORE calling bot.quit()
|
|
106
|
+
// to avoid race condition where 'end' fires before listeners are attached
|
|
107
|
+
|
|
108
|
+
// Handle successful disconnection
|
|
109
|
+
bot.once('end', () => {
|
|
110
|
+
if (!resolved) {
|
|
111
|
+
resolved = true;
|
|
112
|
+
clearTimeout(timeoutId);
|
|
113
|
+
// Force cleanup to ensure all timers and listeners are cleared
|
|
114
|
+
this._forceCleanup(bot);
|
|
115
|
+
resolve({ success: true, reason: 'Clean disconnect' });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Handle disconnection error
|
|
120
|
+
bot.once('error', (err) => {
|
|
121
|
+
if (!resolved) {
|
|
122
|
+
resolved = true;
|
|
123
|
+
clearTimeout(timeoutId);
|
|
124
|
+
// Force cleanup on error
|
|
125
|
+
this._forceCleanup(bot);
|
|
126
|
+
resolve({ success: false, reason: `Disconnect error: ${err.message}` });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Initiate quit AFTER event listeners are set up
|
|
131
|
+
// Use bot.quit() for graceful disconnect instead of socket.destroy()
|
|
132
|
+
// This gives the server time to properly clean up player state
|
|
133
|
+
try {
|
|
134
|
+
bot.quit();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!resolved) {
|
|
137
|
+
resolved = true;
|
|
138
|
+
clearTimeout(timeoutId);
|
|
139
|
+
// Force cleanup on quit failure
|
|
140
|
+
this._forceCleanup(bot);
|
|
141
|
+
resolve({ success: false, reason: `Quit failed: ${error.message}` });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Force cleanup of bot resources
|
|
149
|
+
* Clears all event listeners, timers, and destroys the socket
|
|
150
|
+
* @private
|
|
151
|
+
* @param {Object} bot - Mineflayer bot instance
|
|
152
|
+
*/
|
|
153
|
+
static _forceCleanup(bot) {
|
|
154
|
+
if (!bot) return;
|
|
155
|
+
|
|
156
|
+
// Remove all event listeners to prevent further callbacks
|
|
157
|
+
try {
|
|
158
|
+
bot.removeAllListeners();
|
|
159
|
+
} catch (e) {
|
|
160
|
+
// Ignore errors
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Clean up the underlying client connection
|
|
164
|
+
if (bot._client) {
|
|
165
|
+
try {
|
|
166
|
+
// Remove all listeners from the client
|
|
167
|
+
bot._client.removeAllListeners();
|
|
168
|
+
// End the connection
|
|
169
|
+
bot._client.end();
|
|
170
|
+
// Destroy the socket forcefully
|
|
171
|
+
if (bot._client.socket) {
|
|
172
|
+
bot._client.socket.destroy();
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
// Ignore errors
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Clear any standard output streams that might have listeners
|
|
180
|
+
if (bot._serverCertificate) {
|
|
181
|
+
try {
|
|
182
|
+
bot._serverCertificate.removeAllListeners();
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// Ignore
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Wait for a specific event on a bot
|
|
191
|
+
* @param {Object} bot - Mineflayer bot instance
|
|
192
|
+
* @param {string} eventName - Event to wait for
|
|
193
|
+
* @param {Object} options - Options
|
|
194
|
+
* @param {number} [options.timeout] - Max time to wait (default: 5000ms)
|
|
195
|
+
* @returns {Promise<any>} Event data
|
|
196
|
+
*/
|
|
197
|
+
static async waitForEvent(bot, eventName, options = {}) {
|
|
198
|
+
const { timeout = 5000 } = options;
|
|
199
|
+
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const timeoutId = setTimeout(() => {
|
|
202
|
+
bot.removeAllListeners(eventName);
|
|
203
|
+
reject(new Error(`Timeout waiting for event "${eventName}" after ${timeout}ms`));
|
|
204
|
+
}, timeout);
|
|
205
|
+
|
|
206
|
+
bot.once(eventName, (...args) => {
|
|
207
|
+
clearTimeout(timeoutId);
|
|
208
|
+
resolve(args.length === 1 ? args[0] : args);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Check if a bot is ready for operations
|
|
215
|
+
* @param {Object} bot - Mineflayer bot instance
|
|
216
|
+
* @returns {boolean} True if bot is spawned and ready
|
|
217
|
+
*/
|
|
218
|
+
static isBotReady(bot) {
|
|
219
|
+
return bot && bot.entity && typeof bot.health === 'number';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get bot state information
|
|
224
|
+
* @param {Object} bot - Mineflayer bot instance
|
|
225
|
+
* @returns {Object} Bot state info
|
|
226
|
+
*/
|
|
227
|
+
static getBotInfo(bot) {
|
|
228
|
+
if (!bot) {
|
|
229
|
+
return { connected: false, spawned: false, username: null };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
connected: true,
|
|
234
|
+
spawned: !!bot.entity,
|
|
235
|
+
username: bot.username,
|
|
236
|
+
health: bot.health,
|
|
237
|
+
position: bot.entity ? bot.entity.position : null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { BotLifecycleManager };
|
package/lib/BotPool.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BotPool - Manages a collection of Mineflayer bots with proper lifecycle
|
|
3
|
+
* Ensures cleanup and provides query methods
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { ConnectionState } = require('./ConnectionState.js');
|
|
7
|
+
const { BotLifecycleManager } = require('./BotLifecycleManager.js');
|
|
8
|
+
|
|
9
|
+
class BotPool {
|
|
10
|
+
/**
|
|
11
|
+
* Create a BotPool
|
|
12
|
+
* @param {Object} options - Options
|
|
13
|
+
* @param {Function} [options.createBotFn] - Function to create bots
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.bots = new Map(); // username -> { bot, state, createdAt }
|
|
17
|
+
this.createBotFn = options.createBotFn;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Add a bot to the pool
|
|
22
|
+
* @param {string} username - Bot username
|
|
23
|
+
* @param {Object} bot - Mineflayer bot instance
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
add(username, bot) {
|
|
27
|
+
if (this.bots.has(username)) {
|
|
28
|
+
throw new Error(`Bot "${username}" already exists in pool`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.bots.set(username, {
|
|
32
|
+
bot,
|
|
33
|
+
state: ConnectionState.SPAWNED,
|
|
34
|
+
createdAt: new Date()
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove a bot from the pool (without disconnecting)
|
|
40
|
+
* @param {string} username - Bot username
|
|
41
|
+
* @returns {boolean} True if bot was removed
|
|
42
|
+
*/
|
|
43
|
+
remove(username) {
|
|
44
|
+
return this.bots.delete(username);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get a bot by username
|
|
49
|
+
* @param {string} username - Bot username
|
|
50
|
+
* @returns {Object|null} Bot instance or null
|
|
51
|
+
*/
|
|
52
|
+
get(username) {
|
|
53
|
+
const entry = this.bots.get(username);
|
|
54
|
+
return entry ? entry.bot : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get a bot entry with metadata
|
|
59
|
+
* @param {string} username - Bot username
|
|
60
|
+
* @returns {Object|null} Bot entry or null
|
|
61
|
+
*/
|
|
62
|
+
getEntry(username) {
|
|
63
|
+
return this.bots.get(username) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a bot exists in the pool
|
|
68
|
+
* @param {string} username - Bot username
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
has(username) {
|
|
72
|
+
return this.bots.has(username);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get all bot usernames
|
|
77
|
+
* @returns {Array<string>}
|
|
78
|
+
*/
|
|
79
|
+
getUsernames() {
|
|
80
|
+
return Array.from(this.bots.keys());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get all bots
|
|
85
|
+
* @returns {Array<Object>} Array of bot instances
|
|
86
|
+
*/
|
|
87
|
+
getAll() {
|
|
88
|
+
return Array.from(this.bots.values()).map(entry => entry.bot);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get pool size
|
|
93
|
+
* @returns {number}
|
|
94
|
+
*/
|
|
95
|
+
size() {
|
|
96
|
+
return this.bots.size;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if pool is empty
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
isEmpty() {
|
|
104
|
+
return this.bots.size === 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Quit and remove a bot from the pool
|
|
109
|
+
* @param {string} username - Bot username
|
|
110
|
+
* @param {Object} options - Options
|
|
111
|
+
* @returns {Promise<{success: boolean, reason: string}>}
|
|
112
|
+
*/
|
|
113
|
+
async quitBot(username, options = {}) {
|
|
114
|
+
const entry = this.bots.get(username);
|
|
115
|
+
if (!entry) {
|
|
116
|
+
return { success: false, reason: `Bot "${username}" not found in pool` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = await BotLifecycleManager.quitBot(entry.bot, options);
|
|
120
|
+
this.bots.delete(username);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Quit all bots in the pool
|
|
126
|
+
* @param {Object} options - Options
|
|
127
|
+
* @returns {Promise<Array<{username: string, result: Object}>>}
|
|
128
|
+
*/
|
|
129
|
+
async quitAll(options = {}) {
|
|
130
|
+
const results = [];
|
|
131
|
+
const usernames = this.getUsernames();
|
|
132
|
+
|
|
133
|
+
for (const username of usernames) {
|
|
134
|
+
const result = await this.quitBot(username, options);
|
|
135
|
+
results.push({ username, result });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get health status of the pool
|
|
143
|
+
* @returns {Object} Pool health info
|
|
144
|
+
*/
|
|
145
|
+
getHealth() {
|
|
146
|
+
const bots = [];
|
|
147
|
+
let healthyCount = 0;
|
|
148
|
+
|
|
149
|
+
for (const [username, entry] of this.bots.entries()) {
|
|
150
|
+
const isReady = BotLifecycleManager.isBotReady(entry.bot);
|
|
151
|
+
if (isReady) healthyCount++;
|
|
152
|
+
|
|
153
|
+
bots.push({
|
|
154
|
+
username,
|
|
155
|
+
state: entry.state,
|
|
156
|
+
ready: isReady,
|
|
157
|
+
createdAt: entry.createdAt
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
total: this.bots.size,
|
|
163
|
+
healthy: healthyCount,
|
|
164
|
+
unhealthy: this.bots.size - healthyCount,
|
|
165
|
+
bots
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Find bots by state
|
|
171
|
+
* @param {string} state - ConnectionState to filter by
|
|
172
|
+
* @returns {Array<Object>} Array of matching bots
|
|
173
|
+
*/
|
|
174
|
+
findByState(state) {
|
|
175
|
+
return Array.from(this.bots.entries())
|
|
176
|
+
.filter(([_, entry]) => entry.state === state)
|
|
177
|
+
.map(([username, entry]) => ({ username, ...entry }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get bots that are ready for operations
|
|
182
|
+
* @returns {Array<Object>} Array of ready bots
|
|
183
|
+
*/
|
|
184
|
+
getReadyBots() {
|
|
185
|
+
return Array.from(this.bots.entries())
|
|
186
|
+
.filter(([_, entry]) => BotLifecycleManager.isBotReady(entry.bot))
|
|
187
|
+
.map(([username, entry]) => ({ username, bot: entry.bot }));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Clear the pool (remove all references without disconnecting)
|
|
192
|
+
* Use quitAll() for proper cleanup
|
|
193
|
+
* @returns {number} Number of bots removed
|
|
194
|
+
*/
|
|
195
|
+
clear() {
|
|
196
|
+
const size = this.bots.size;
|
|
197
|
+
this.bots.clear();
|
|
198
|
+
return size;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { BotPool };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectionState - Represents the state of a bot or connection
|
|
3
|
+
* Uses freeze() to create immutable enum-like values
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class ConnectionState {
|
|
7
|
+
static get DISCONNECTED() { return 'DISCONNECTED'; }
|
|
8
|
+
static get CONNECTING() { return 'CONNECTING'; }
|
|
9
|
+
static get CONNECTED() { return 'CONNECTED'; }
|
|
10
|
+
static get SPAWNING() { return 'SPAWNING'; }
|
|
11
|
+
static get SPAWNED() { return 'SPAWNED'; }
|
|
12
|
+
static get ERROR() { return 'ERROR'; }
|
|
13
|
+
static get DISCONNECTING() { return 'DISCONNECTING'; }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if state is a terminal state (no further transitions possible)
|
|
17
|
+
* @param {string} state - State to check
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
static isTerminal(state) {
|
|
21
|
+
return [
|
|
22
|
+
ConnectionState.DISCONNECTED,
|
|
23
|
+
ConnectionState.ERROR
|
|
24
|
+
].includes(state);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if state allows bot operations
|
|
29
|
+
* @param {string} state - State to check
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
static canPerformBotOperations(state) {
|
|
33
|
+
return state === ConnectionState.SPAWNED;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if state is a transitioning state
|
|
38
|
+
* @param {string} state - State to check
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
static isTransitioning(state) {
|
|
42
|
+
return [
|
|
43
|
+
ConnectionState.CONNECTING,
|
|
44
|
+
ConnectionState.SPAWNING,
|
|
45
|
+
ConnectionState.DISCONNECTING
|
|
46
|
+
].includes(state);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { ConnectionState };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServerHealthChecker - Verifies Minecraft server is ready for bot connections
|
|
3
|
+
* Uses RCON for fast health checks instead of creating test bots
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { Rcon } = require('rcon-client');
|
|
7
|
+
|
|
8
|
+
class ServerHealthChecker {
|
|
9
|
+
/**
|
|
10
|
+
* Create a ServerHealthChecker instance
|
|
11
|
+
* @param {Object} config - Server configuration
|
|
12
|
+
* @param {string} [config.host] - Server host
|
|
13
|
+
* @param {number} [config.port] - Server port (for Minecraft, not RCON)
|
|
14
|
+
* @param {string} [config.auth] - Auth mode
|
|
15
|
+
* @param {string} [config.rconHost] - RCON host (defaults to host)
|
|
16
|
+
* @param {number} [config.rconPort] - RCON port (default: 25575)
|
|
17
|
+
* @param {string} [config.rconPassword] - RCON password (default: 'cavarest')
|
|
18
|
+
*/
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
this.host = config?.host || 'localhost';
|
|
21
|
+
this.port = config?.port || 25565;
|
|
22
|
+
this.auth = config?.auth || 'offline';
|
|
23
|
+
this.rconHost = config?.rconHost || this.host;
|
|
24
|
+
this.rconPort = config?.rconPort || 25575;
|
|
25
|
+
this.rconPassword = config?.rconPassword || 'cavarest';
|
|
26
|
+
this.ready = false;
|
|
27
|
+
this.lastCheckTime = null;
|
|
28
|
+
this.lastCheckResult = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Perform a single health check using RCON
|
|
33
|
+
* @returns {Promise<{success: boolean, reason: string, latency: number}>}
|
|
34
|
+
*/
|
|
35
|
+
async check() {
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const client = await Rcon.connect({
|
|
40
|
+
host: this.rconHost,
|
|
41
|
+
port: this.rconPort,
|
|
42
|
+
password: this.rconPassword
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Try to send a simple command to verify server is responsive
|
|
46
|
+
await client.send('list');
|
|
47
|
+
await client.end();
|
|
48
|
+
|
|
49
|
+
const latency = Date.now() - startTime;
|
|
50
|
+
|
|
51
|
+
this.lastCheckTime = new Date();
|
|
52
|
+
this.lastCheckResult = { success: true };
|
|
53
|
+
this.ready = true;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
reason: 'Server is ready',
|
|
58
|
+
latency
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const latency = Date.now() - startTime;
|
|
62
|
+
|
|
63
|
+
this.lastCheckTime = new Date();
|
|
64
|
+
this.lastCheckResult = { success: false, error: error.message };
|
|
65
|
+
this.ready = false;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
reason: error.message,
|
|
70
|
+
latency
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wait for server to become ready, with polling
|
|
77
|
+
* @param {Object} options - Options
|
|
78
|
+
* @param {number} [options.timeout] - Total timeout (default: 120000ms)
|
|
79
|
+
* @param {number} [options.interval] - Polling interval (default: 3000ms)
|
|
80
|
+
* @param {boolean} [options.fastFail] - Fail immediately on first error (default: false)
|
|
81
|
+
* @returns {Promise<{success: boolean, checks: number, totalLatency: number}>}
|
|
82
|
+
*/
|
|
83
|
+
async waitForReady(options = {}) {
|
|
84
|
+
const {
|
|
85
|
+
timeout = 120000,
|
|
86
|
+
interval = 3000,
|
|
87
|
+
fastFail = false
|
|
88
|
+
} = options;
|
|
89
|
+
|
|
90
|
+
const startTime = Date.now();
|
|
91
|
+
let checks = 0;
|
|
92
|
+
let totalLatency = 0;
|
|
93
|
+
|
|
94
|
+
while (Date.now() - startTime < timeout) {
|
|
95
|
+
checks++;
|
|
96
|
+
|
|
97
|
+
const result = await this.check();
|
|
98
|
+
totalLatency += result.latency;
|
|
99
|
+
|
|
100
|
+
if (result.success) {
|
|
101
|
+
this.ready = true;
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
checks,
|
|
105
|
+
totalLatency
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// If fast fail is enabled, don't retry
|
|
110
|
+
if (fastFail) {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Wait before retrying
|
|
115
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.ready = false;
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
checks,
|
|
122
|
+
totalLatency
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if server was marked ready on last check
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
isReady() {
|
|
131
|
+
return this.ready;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get last check result
|
|
136
|
+
* @returns {{success: boolean|null, error: string|null, time: Date|null}}
|
|
137
|
+
*/
|
|
138
|
+
getLastCheck() {
|
|
139
|
+
return {
|
|
140
|
+
success: this.lastCheckResult?.success ?? null,
|
|
141
|
+
error: this.lastCheckResult?.error ?? null,
|
|
142
|
+
time: this.lastCheckTime
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reset the ready state
|
|
148
|
+
*/
|
|
149
|
+
reset() {
|
|
150
|
+
this.ready = false;
|
|
151
|
+
this.lastCheckTime = null;
|
|
152
|
+
this.lastCheckResult = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create a health check report
|
|
157
|
+
* @returns {Object} Health check report
|
|
158
|
+
*/
|
|
159
|
+
getReport() {
|
|
160
|
+
return {
|
|
161
|
+
ready: this.ready,
|
|
162
|
+
lastCheck: this.getLastCheck(),
|
|
163
|
+
config: {
|
|
164
|
+
host: this.host,
|
|
165
|
+
port: this.port,
|
|
166
|
+
auth: this.auth,
|
|
167
|
+
rconHost: this.rconHost,
|
|
168
|
+
rconPort: this.rconPort
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { ServerHealthChecker };
|
package/lib/backend.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base PilafBackend class - abstract class defining the interface for all backend implementations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
class PilafBackend {
|
|
6
|
+
/**
|
|
7
|
+
* Connect to the backend (RCON server or Minecraft server)
|
|
8
|
+
* @abstract
|
|
9
|
+
* @param {Object} config - Connection configuration
|
|
10
|
+
* @returns {Promise<void>}
|
|
11
|
+
*/
|
|
12
|
+
async connect(config) {
|
|
13
|
+
throw new Error('Method "connect()" must be implemented.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Disconnect from the backend
|
|
18
|
+
* @abstract
|
|
19
|
+
* @returns {Promise<void>}
|
|
20
|
+
*/
|
|
21
|
+
async disconnect() {
|
|
22
|
+
throw new Error('Method "disconnect()" must be implemented.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Send a command to the server
|
|
27
|
+
* @abstract
|
|
28
|
+
* @param {string} command - The command to send
|
|
29
|
+
* @returns {Promise<{raw: string, parsed?: any}>}
|
|
30
|
+
*/
|
|
31
|
+
async sendCommand(command) {
|
|
32
|
+
throw new Error('Method "sendCommand()" must be implemented.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a bot/player instance
|
|
37
|
+
* @abstract
|
|
38
|
+
* @param {Object} options - Bot options (including username)
|
|
39
|
+
* @returns {Promise<Object>} Bot instance
|
|
40
|
+
*/
|
|
41
|
+
async createBot(options) {
|
|
42
|
+
throw new Error('Method "createBot()" must be implemented.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get all entities on the server
|
|
47
|
+
* @abstract
|
|
48
|
+
* @param {string} selector - Entity selector (optional)
|
|
49
|
+
* @returns {Promise<Array>} Array of entities
|
|
50
|
+
*/
|
|
51
|
+
async getEntities(selector) {
|
|
52
|
+
throw new Error('Method "getEntities()" must be implemented.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get player inventory
|
|
57
|
+
* @abstract
|
|
58
|
+
* @param {string} username - Player username
|
|
59
|
+
* @returns {Promise<Object>} Player inventory
|
|
60
|
+
*/
|
|
61
|
+
async getPlayerInventory(username) {
|
|
62
|
+
throw new Error('Method "getPlayerInventory()" must be implemented.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get block at specific coordinates
|
|
67
|
+
* @abstract
|
|
68
|
+
* @param {number} x - X coordinate
|
|
69
|
+
* @param {number} y - Y coordinate
|
|
70
|
+
* @param {number} z - Z coordinate
|
|
71
|
+
* @returns {Promise<Object>} Block data
|
|
72
|
+
*/
|
|
73
|
+
async getBlockAt(x, y, z) {
|
|
74
|
+
throw new Error('Method "getBlockAt()" must be implemented.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { PilafBackend };
|
package/lib/factory.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PilafBackendFactory - Factory for creating backend instances
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { PilafBackend } = require('./backend.js');
|
|
6
|
+
const { RconBackend } = require('./rcon-backend.js');
|
|
7
|
+
const { MineflayerBackend } = require('./mineflayer-backend.js');
|
|
8
|
+
|
|
9
|
+
class PilafBackendFactory {
|
|
10
|
+
/**
|
|
11
|
+
* Create a backend instance based on type
|
|
12
|
+
* @param {string} type - Backend type ('rcon' or 'mineflayer')
|
|
13
|
+
* @param {Object} config - Configuration object
|
|
14
|
+
* @returns {PilafBackend} Connected backend instance
|
|
15
|
+
*/
|
|
16
|
+
static create(type, config = {}) {
|
|
17
|
+
switch (type.toLowerCase()) {
|
|
18
|
+
case 'rcon':
|
|
19
|
+
return new RconBackend().connect(config);
|
|
20
|
+
|
|
21
|
+
case 'mineflayer':
|
|
22
|
+
return new MineflayerBackend().connect(config);
|
|
23
|
+
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`Unknown backend type: ${type}. Supported types: 'rcon', 'mineflayer'`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { PilafBackendFactory };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pilaf Backends Package - Main export
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { PilafBackend } = require('./backend.js');
|
|
6
|
+
const { RconBackend } = require('./rcon-backend.js');
|
|
7
|
+
const { MineflayerBackend } = require('./mineflayer-backend.js');
|
|
8
|
+
const { PilafBackendFactory } = require('./factory.js');
|
|
9
|
+
const { ConnectionState } = require('./ConnectionState.js');
|
|
10
|
+
const { BotLifecycleManager } = require('./BotLifecycleManager.js');
|
|
11
|
+
const { ServerHealthChecker } = require('./ServerHealthChecker.js');
|
|
12
|
+
const { BotPool } = require('./BotPool.js');
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
PilafBackend,
|
|
16
|
+
RconBackend,
|
|
17
|
+
MineflayerBackend,
|
|
18
|
+
PilafBackendFactory,
|
|
19
|
+
ConnectionState,
|
|
20
|
+
BotLifecycleManager,
|
|
21
|
+
ServerHealthChecker,
|
|
22
|
+
BotPool
|
|
23
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MineflayerBackend - Mineflayer implementation for Pilaf backend
|
|
3
|
+
* Refactored with proper OOP architecture and separation of concerns
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const mineflayer = require('mineflayer');
|
|
7
|
+
const { PilafBackend } = require('./backend.js');
|
|
8
|
+
const { BotPool } = require('./BotPool.js');
|
|
9
|
+
const { BotLifecycleManager } = require('./BotLifecycleManager.js');
|
|
10
|
+
const { ServerHealthChecker } = require('./ServerHealthChecker.js');
|
|
11
|
+
|
|
12
|
+
class MineflayerBackend extends PilafBackend {
|
|
13
|
+
/**
|
|
14
|
+
* Create a MineflayerBackend
|
|
15
|
+
* @param {Object} options - Options
|
|
16
|
+
*/
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
super();
|
|
19
|
+
this._host = null;
|
|
20
|
+
this._port = null;
|
|
21
|
+
this._auth = null;
|
|
22
|
+
this._connectConfig = {};
|
|
23
|
+
this._botPool = new BotPool();
|
|
24
|
+
this._healthChecker = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get server host
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
get host() {
|
|
32
|
+
return this._host;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get server port
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
get port() {
|
|
40
|
+
return this._port;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get authentication mode
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
get auth() {
|
|
48
|
+
return this._auth;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Connect to server
|
|
53
|
+
* @param {Object} config - Configuration object
|
|
54
|
+
* @param {string} config.host - Server host
|
|
55
|
+
* @param {number} config.port - Server port
|
|
56
|
+
* @param {string} [config.auth] - Auth mode ('offline' or 'mojang')
|
|
57
|
+
* @param {string} [config.rconHost] - RCON host (for health checks, defaults to host)
|
|
58
|
+
* @param {number} [config.rconPort] - RCON port (for health checks, default: 25575)
|
|
59
|
+
* @param {string} [config.rconPassword] - RCON password (for health checks, default: 'cavarest')
|
|
60
|
+
* @returns {Promise<this>}
|
|
61
|
+
*/
|
|
62
|
+
async connect(config) {
|
|
63
|
+
this._host = config?.host || 'localhost';
|
|
64
|
+
this._port = config?.port || 25565;
|
|
65
|
+
this._auth = config?.auth || 'offline';
|
|
66
|
+
|
|
67
|
+
// Store complete connection config
|
|
68
|
+
this._connectConfig = {
|
|
69
|
+
host: this._host,
|
|
70
|
+
port: this._port,
|
|
71
|
+
auth: this._auth,
|
|
72
|
+
...config
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Initialize health checker with RCON config for fast checks
|
|
76
|
+
this._healthChecker = new ServerHealthChecker({
|
|
77
|
+
host: this._host,
|
|
78
|
+
port: this._port,
|
|
79
|
+
auth: this._auth,
|
|
80
|
+
rconHost: config?.rconHost || this._host,
|
|
81
|
+
rconPort: config?.rconPort || 25575,
|
|
82
|
+
rconPassword: config?.rconPassword || 'cavarest'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Disconnect all bots
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
async disconnect() {
|
|
93
|
+
const results = await this._botPool.quitAll({ disconnectTimeout: 10000 });
|
|
94
|
+
this._botPool.clear();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send command via default bot
|
|
99
|
+
* @param {string} command - Command to send
|
|
100
|
+
* @returns {Promise<{raw: string, parsed?: any}>}
|
|
101
|
+
*/
|
|
102
|
+
async sendCommand(command) {
|
|
103
|
+
const readyBots = this._botPool.getReadyBots();
|
|
104
|
+
|
|
105
|
+
if (readyBots.length === 0) {
|
|
106
|
+
throw new Error('No bot available. Create a bot first using createBot()');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const bot = readyBots[0].bot;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
bot.chat(command);
|
|
113
|
+
return { raw: '' };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new Error(`Failed to send command: ${error.message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a Mineflayer bot with proper lifecycle management
|
|
121
|
+
* @param {Object} options - Bot options
|
|
122
|
+
* @param {string} options.username - Bot username (required)
|
|
123
|
+
* @param {string} [options.host] - Server host (optional, uses connect config)
|
|
124
|
+
* @param {number} [options.port] - Server port (optional, uses connect config)
|
|
125
|
+
* @param {string} [options.auth] - Auth mode (optional, uses connect config)
|
|
126
|
+
* @param {number} [options.spawnTimeout] - Max time to wait for spawn (default: 30000ms)
|
|
127
|
+
* @param {Object} [options] - Additional mineflayer options
|
|
128
|
+
* @returns {Promise<Object>} Bot instance
|
|
129
|
+
*/
|
|
130
|
+
async createBot(options) {
|
|
131
|
+
const username = options?.username;
|
|
132
|
+
if (!username) {
|
|
133
|
+
throw new Error('username is required in options');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this._botPool.has(username)) {
|
|
137
|
+
throw new Error(`Bot "${username}" already exists`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Merge connect config with bot options (bot options take precedence)
|
|
141
|
+
const botConfig = {
|
|
142
|
+
...this._connectConfig,
|
|
143
|
+
...options
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Create and spawn bot using lifecycle manager
|
|
147
|
+
const { bot } = await BotLifecycleManager.createAndSpawnBot(
|
|
148
|
+
() => mineflayer.createBot(botConfig),
|
|
149
|
+
botConfig
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Add to pool
|
|
153
|
+
this._botPool.add(username, bot);
|
|
154
|
+
|
|
155
|
+
return bot;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Quit a bot and remove from pool
|
|
160
|
+
* @param {Object} bot - Bot instance to quit
|
|
161
|
+
* @returns {Promise<{success: boolean, reason: string}>}
|
|
162
|
+
*/
|
|
163
|
+
async quitBot(bot) {
|
|
164
|
+
// Find username by bot reference
|
|
165
|
+
for (const [username, entry] of this._botPool.bots.entries()) {
|
|
166
|
+
if (entry.bot === bot) {
|
|
167
|
+
return await this._botPool.quitBot(username);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { success: false, reason: 'Bot not found in pool' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if server is ready for bot connections
|
|
176
|
+
* @param {Object} options - Options
|
|
177
|
+
* @param {number} [options.timeout] - Total timeout (default: 120000ms)
|
|
178
|
+
* @returns {Promise<{success: boolean, checks: number, totalLatency: number}>}
|
|
179
|
+
*/
|
|
180
|
+
async waitForServerReady(options = {}) {
|
|
181
|
+
if (!this._healthChecker) {
|
|
182
|
+
throw new Error('Backend not connected. Call connect() first.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return await this._healthChecker.waitForReady(options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get health checker instance
|
|
190
|
+
* @returns {ServerHealthChecker}
|
|
191
|
+
*/
|
|
192
|
+
getHealthChecker() {
|
|
193
|
+
return this._healthChecker;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get bot pool instance
|
|
198
|
+
* @returns {BotPool}
|
|
199
|
+
*/
|
|
200
|
+
getBotPool() {
|
|
201
|
+
return this._botPool;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get entities from all bots
|
|
206
|
+
* @param {string} selector - Entity selector (optional, unused in Mineflayer)
|
|
207
|
+
* @returns {Promise<Array>} Array of entities
|
|
208
|
+
*/
|
|
209
|
+
async getEntities(selector) {
|
|
210
|
+
const allEntities = [];
|
|
211
|
+
const bots = this._botPool.getAll();
|
|
212
|
+
|
|
213
|
+
for (const bot of bots) {
|
|
214
|
+
if (bot.entities) {
|
|
215
|
+
Object.values(bot.entities).forEach(entity => {
|
|
216
|
+
// Use the built-in getCustomName() method from prismarine-entity
|
|
217
|
+
// This properly extracts custom name from entity.metadata[2]
|
|
218
|
+
let customName = null;
|
|
219
|
+
try {
|
|
220
|
+
if (typeof entity.getCustomName === 'function') {
|
|
221
|
+
const customNameObj = entity.getCustomName();
|
|
222
|
+
if (customNameObj) {
|
|
223
|
+
// ChatMessage object has a toString() method or text property
|
|
224
|
+
customName = customNameObj.toString ? customNameObj.toString() : (customNameObj.text || customNameObj);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
// Ignore errors from getCustomName
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
allEntities.push({
|
|
232
|
+
id: entity.id,
|
|
233
|
+
name: entity.name || entity.username,
|
|
234
|
+
displayName: entity.displayName,
|
|
235
|
+
customName: customName,
|
|
236
|
+
type: entity.type,
|
|
237
|
+
position: entity.position,
|
|
238
|
+
health: entity.health
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return allEntities;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get player inventory
|
|
249
|
+
* @param {string} username - Player username
|
|
250
|
+
* @returns {Promise<Object>} Player inventory
|
|
251
|
+
*/
|
|
252
|
+
async getPlayerInventory(username) {
|
|
253
|
+
const bot = this._botPool.get(username);
|
|
254
|
+
|
|
255
|
+
if (!bot) {
|
|
256
|
+
throw new Error(`Bot "${username}" not found`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
slots: bot.inventory.slots,
|
|
261
|
+
items: bot.inventory.items()
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get block at coordinates
|
|
267
|
+
* @param {number} x - X coordinate
|
|
268
|
+
* @param {number} y - Y coordinate
|
|
269
|
+
* @param {number} z - Z coordinate
|
|
270
|
+
* @returns {Promise<Object>} Block data
|
|
271
|
+
*/
|
|
272
|
+
async getBlockAt(x, y, z) {
|
|
273
|
+
const readyBots = this._botPool.getReadyBots();
|
|
274
|
+
|
|
275
|
+
if (readyBots.length === 0) {
|
|
276
|
+
throw new Error('No bot available');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const bot = readyBots[0].bot;
|
|
280
|
+
const block = bot.blockAt({ x, y, z });
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
type: block?.type,
|
|
284
|
+
name: block?.name,
|
|
285
|
+
position: block?.position,
|
|
286
|
+
metadata: block?.metadata
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { MineflayerBackend };
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RconBackend - RCON implementation for Pilaf backend
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { Rcon } = require('rcon-client');
|
|
6
|
+
const { PilafBackend } = require('./backend.js');
|
|
7
|
+
|
|
8
|
+
class RconBackend extends PilafBackend {
|
|
9
|
+
constructor() {
|
|
10
|
+
super();
|
|
11
|
+
this.client = null;
|
|
12
|
+
this.connected = false;
|
|
13
|
+
this._connectTimeout = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Connect to RCON server
|
|
18
|
+
* @param {Object} config - Configuration object
|
|
19
|
+
* @param {string} config.host - RCON host
|
|
20
|
+
* @param {number} config.port - RCON port
|
|
21
|
+
* @param {string} config.password - RCON password
|
|
22
|
+
* @param {number} [config.timeout=30000] - Connection timeout in milliseconds
|
|
23
|
+
* @returns {Promise<void>}
|
|
24
|
+
*/
|
|
25
|
+
async connect(config) {
|
|
26
|
+
const host = config?.host || 'localhost';
|
|
27
|
+
const port = config?.port || 25575;
|
|
28
|
+
const password = config?.password || '';
|
|
29
|
+
const timeout = config?.timeout || 30000; // 30 second default timeout
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Add timeout to prevent hanging on slow/unresponsive RCON servers
|
|
33
|
+
// Store timeout ID so we can clear it on success
|
|
34
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
35
|
+
this._connectTimeout = setTimeout(() => {
|
|
36
|
+
this._connectTimeout = null;
|
|
37
|
+
reject(new Error(`RCON connection timeout after ${timeout}ms`));
|
|
38
|
+
}, timeout);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
this.client = await Promise.race([
|
|
42
|
+
Rcon.connect({ host, port, password }),
|
|
43
|
+
timeoutPromise
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Clear timeout if connection succeeded
|
|
47
|
+
if (this._connectTimeout) {
|
|
48
|
+
clearTimeout(this._connectTimeout);
|
|
49
|
+
this._connectTimeout = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.connected = true;
|
|
53
|
+
return this;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// Clean up timeout on error
|
|
56
|
+
if (this._connectTimeout) {
|
|
57
|
+
clearTimeout(this._connectTimeout);
|
|
58
|
+
this._connectTimeout = null;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Failed to connect to RCON: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Disconnect from RCON server
|
|
66
|
+
* @returns {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
async disconnect() {
|
|
69
|
+
// Clear connection timeout if still pending
|
|
70
|
+
if (this._connectTimeout) {
|
|
71
|
+
clearTimeout(this._connectTimeout);
|
|
72
|
+
this._connectTimeout = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.client) {
|
|
76
|
+
// Store the client reference before clearing
|
|
77
|
+
const client = this.client;
|
|
78
|
+
this.client = null;
|
|
79
|
+
this.connected = false;
|
|
80
|
+
|
|
81
|
+
// Destroy the socket immediately without waiting for TCP FIN
|
|
82
|
+
// This prevents Jest from hanging on open handles
|
|
83
|
+
if (client.socket) {
|
|
84
|
+
try {
|
|
85
|
+
client.socket.destroy();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// Socket might already be destroyed
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Clear all event listeners from the internal emitter
|
|
92
|
+
if (client.emitter) {
|
|
93
|
+
try {
|
|
94
|
+
client.emitter.removeAllListeners();
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// Emitter might already be cleaned up
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Send command via RCON
|
|
104
|
+
* @param {string} command - Command to send
|
|
105
|
+
* @param {number} [timeout=10000] - Timeout in milliseconds
|
|
106
|
+
* @returns {Promise<{raw: string, parsed?: any}>}
|
|
107
|
+
*/
|
|
108
|
+
async sendCommand(command, timeout = 10000) {
|
|
109
|
+
if (!this.connected || !this.client) {
|
|
110
|
+
throw new Error('Not connected to RCON server');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create a single timeout that we can cancel
|
|
114
|
+
let timeoutId = null;
|
|
115
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
116
|
+
timeoutId = setTimeout(() => {
|
|
117
|
+
reject(new Error(`RCON command timeout after ${timeout}ms`));
|
|
118
|
+
}, timeout);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Race the actual send against the timeout
|
|
123
|
+
const response = await Promise.race([
|
|
124
|
+
this.client.send(command),
|
|
125
|
+
timeoutPromise
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// Clear timeout if command succeeded
|
|
129
|
+
if (timeoutId !== null) {
|
|
130
|
+
clearTimeout(timeoutId);
|
|
131
|
+
timeoutId = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return this._parseResponse(response);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// Clear timeout on error
|
|
137
|
+
if (timeoutId !== null) {
|
|
138
|
+
clearTimeout(timeoutId);
|
|
139
|
+
timeoutId = null;
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`Failed to send command: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Send command via RCON (alias for sendCommand)
|
|
147
|
+
* @param {string} command - Command to send
|
|
148
|
+
* @returns {Promise<{raw: string, parsed?: any}>}
|
|
149
|
+
*/
|
|
150
|
+
async send(command) {
|
|
151
|
+
return this.sendCommand(command);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse RCON response
|
|
156
|
+
* @private
|
|
157
|
+
* @param {string} response - Raw response
|
|
158
|
+
* @returns {{raw: string, parsed?: any}}
|
|
159
|
+
*/
|
|
160
|
+
_parseResponse(response) {
|
|
161
|
+
const result = { raw: response };
|
|
162
|
+
|
|
163
|
+
// Try to parse as JSON
|
|
164
|
+
try {
|
|
165
|
+
result.parsed = JSON.parse(response);
|
|
166
|
+
} catch {
|
|
167
|
+
// Not JSON, keep raw only
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* RCON doesn't support bot creation
|
|
175
|
+
*/
|
|
176
|
+
async createBot() {
|
|
177
|
+
throw new Error('RCON backend does not support bot creation');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* RCON doesn't support entity listing
|
|
182
|
+
*/
|
|
183
|
+
async getEntities() {
|
|
184
|
+
throw new Error('RCON backend does not support entity listing');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get player inventory via RCON command
|
|
189
|
+
* @param {string} username - Player username
|
|
190
|
+
* @returns {Promise<Object>}
|
|
191
|
+
*/
|
|
192
|
+
async getPlayerInventory(username) {
|
|
193
|
+
const response = await this.sendCommand(`data get entity ${username} Inventory`);
|
|
194
|
+
return response.parsed || response;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get block at coordinates via RCON command
|
|
199
|
+
* @param {number} x - X coordinate
|
|
200
|
+
* @param {number} y - Y coordinate
|
|
201
|
+
* @param {number} z - Z coordinate
|
|
202
|
+
* @returns {Promise<Object>}
|
|
203
|
+
*/
|
|
204
|
+
async getBlockAt(x, y, z) {
|
|
205
|
+
const response = await this.sendCommand(`data get block ${x} ${y} ${z}`);
|
|
206
|
+
return response.parsed || response;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { RconBackend };
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pilaf/backends",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "lib/index.js",
|
|
5
|
+
"files": [
|
|
6
|
+
"lib/*.js",
|
|
7
|
+
"lib/**/*.js",
|
|
8
|
+
"!lib/**/*.spec.js",
|
|
9
|
+
"!lib/**/*.test.js",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"mineflayer": "^4.20.1",
|
|
18
|
+
"rcon-client": "^4.2.5"
|
|
19
|
+
}
|
|
20
|
+
}
|