@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.
@@ -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
+ }