balatrobot 0.6.0__py3-none-any.whl

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.

Potentially problematic release.


This version of balatrobot might be problematic. Click here for more details.

balatrobot/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """BalatroBot - Python client for the BalatroBot game API."""
2
+
3
+ from .client import BalatroClient
4
+ from .enums import Actions, Decks, Stakes, State
5
+ from .exceptions import BalatroError
6
+ from .models import G
7
+
8
+ __version__ = "0.6.0"
9
+ __all__ = [
10
+ # Main client
11
+ "BalatroClient",
12
+ # Enums
13
+ "Actions",
14
+ "Decks",
15
+ "Stakes",
16
+ "State",
17
+ # Exception
18
+ "BalatroError",
19
+ # Models
20
+ "G",
21
+ ]
balatrobot/client.py ADDED
@@ -0,0 +1,483 @@
1
+ """Main BalatroBot client for communicating with the game."""
2
+
3
+ import json
4
+ import logging
5
+ import platform
6
+ import re
7
+ import shutil
8
+ import socket
9
+ from pathlib import Path
10
+ from typing import Self
11
+
12
+ from .enums import ErrorCode
13
+ from .exceptions import (
14
+ BalatroError,
15
+ ConnectionFailedError,
16
+ create_exception_from_error_response,
17
+ )
18
+ from .models import APIRequest
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class BalatroClient:
24
+ """Client for communicating with the BalatroBot game API.
25
+
26
+ The client provides methods for game control, state management, and development tools
27
+ including a checkpointing system for saving and loading game states.
28
+
29
+ Attributes:
30
+ host: Host address to connect to
31
+ port: Port number to connect to
32
+ timeout: Socket timeout in seconds
33
+ buffer_size: Socket buffer size in bytes
34
+ _socket: Socket connection to BalatroBot
35
+ """
36
+
37
+ host = "127.0.0.1"
38
+ timeout = 30.0
39
+ buffer_size = 65536
40
+
41
+ def __init__(self, port: int = 12346):
42
+ """Initialize BalatroBot client
43
+
44
+ Args:
45
+ port: Port number to connect to (default: 12346)
46
+ """
47
+ self.port = port
48
+ self._socket: socket.socket | None = None
49
+ self._connected = False
50
+ self._message_buffer = b"" # Buffer for incomplete messages
51
+
52
+ def _receive_complete_message(self) -> bytes:
53
+ """Receive a complete message from the socket, handling message boundaries properly."""
54
+ if not self._connected or not self._socket:
55
+ raise ConnectionFailedError(
56
+ "Socket not connected",
57
+ error_code="E008",
58
+ context={
59
+ "connected": self._connected,
60
+ "socket": self._socket is not None,
61
+ },
62
+ )
63
+
64
+ # Check if we already have a complete message in the buffer
65
+ while b"\n" not in self._message_buffer:
66
+ try:
67
+ chunk = self._socket.recv(self.buffer_size)
68
+ except socket.timeout:
69
+ raise ConnectionFailedError(
70
+ "Socket timeout while receiving data",
71
+ error_code="E008",
72
+ context={
73
+ "timeout": self.timeout,
74
+ "buffer_size": len(self._message_buffer),
75
+ },
76
+ )
77
+ except socket.error as e:
78
+ raise ConnectionFailedError(
79
+ f"Socket error while receiving: {e}",
80
+ error_code="E008",
81
+ context={"error": str(e), "buffer_size": len(self._message_buffer)},
82
+ )
83
+
84
+ if not chunk:
85
+ raise ConnectionFailedError(
86
+ "Connection closed by server",
87
+ error_code="E008",
88
+ context={"buffer_size": len(self._message_buffer)},
89
+ )
90
+ self._message_buffer += chunk
91
+
92
+ # Extract the first complete message
93
+ message_end = self._message_buffer.find(b"\n")
94
+ complete_message = self._message_buffer[:message_end]
95
+
96
+ # Update buffer to remove the processed message
97
+ remaining_data = self._message_buffer[message_end + 1 :]
98
+ self._message_buffer = remaining_data
99
+
100
+ # Log any remaining data for debugging
101
+ if remaining_data:
102
+ logger.warning(f"Data remaining in buffer: {len(remaining_data)} bytes")
103
+ logger.debug(f"Buffer preview: {remaining_data[:100]}...")
104
+
105
+ return complete_message
106
+
107
+ def __enter__(self) -> Self:
108
+ """Enter context manager and connect to the game."""
109
+ self.connect()
110
+ return self
111
+
112
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
113
+ """Exit context manager and disconnect from the game."""
114
+ self.disconnect()
115
+
116
+ def connect(self) -> None:
117
+ """Connect to Balatro TCP server
118
+
119
+ Raises:
120
+ ConnectionFailedError: If not connected to the game
121
+ """
122
+ if self._connected:
123
+ return
124
+
125
+ logger.info(f"Connecting to BalatroBot API at {self.host}:{self.port}")
126
+ try:
127
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
128
+ self._socket.settimeout(self.timeout)
129
+ self._socket.setsockopt(
130
+ socket.SOL_SOCKET, socket.SO_RCVBUF, self.buffer_size
131
+ )
132
+ self._socket.connect((self.host, self.port))
133
+ self._connected = True
134
+ logger.info(
135
+ f"Successfully connected to BalatroBot API at {self.host}:{self.port}"
136
+ )
137
+ except (socket.error, OSError) as e:
138
+ logger.error(f"Failed to connect to {self.host}:{self.port}: {e}")
139
+ raise ConnectionFailedError(
140
+ f"Failed to connect to {self.host}:{self.port}",
141
+ error_code="E008",
142
+ context={"host": self.host, "port": self.port, "error": str(e)},
143
+ ) from e
144
+
145
+ def disconnect(self) -> None:
146
+ """Disconnect from the BalatroBot game API."""
147
+ if self._socket:
148
+ logger.info(f"Disconnecting from BalatroBot API at {self.host}:{self.port}")
149
+ self._socket.close()
150
+ self._socket = None
151
+ self._connected = False
152
+ # Clear message buffer on disconnect
153
+ self._message_buffer = b""
154
+
155
+ def send_message(self, name: str, arguments: dict | None = None) -> dict:
156
+ """Send JSON message to Balatro and receive response
157
+
158
+ Args:
159
+ name: Function name to call
160
+ arguments: Function arguments
161
+
162
+ Returns:
163
+ Response from the game API
164
+
165
+ Raises:
166
+ ConnectionFailedError: If not connected to the game
167
+ BalatroError: If the API returns an error
168
+ """
169
+ if arguments is None:
170
+ arguments = {}
171
+
172
+ if not self._connected or not self._socket:
173
+ raise ConnectionFailedError(
174
+ "Not connected to the game API",
175
+ error_code="E008",
176
+ context={
177
+ "connected": self._connected,
178
+ "socket": self._socket is not None,
179
+ },
180
+ )
181
+
182
+ # Create and validate request
183
+ request = APIRequest(name=name, arguments=arguments)
184
+ logger.debug(f"Sending API request: {name}")
185
+
186
+ try:
187
+ # Send request
188
+ message = request.model_dump_json() + "\n"
189
+ self._socket.send(message.encode())
190
+
191
+ # Receive response using improved message handling
192
+ complete_message = self._receive_complete_message()
193
+
194
+ # Decode and validate the message
195
+ message_str = complete_message.decode().strip()
196
+ logger.debug(f"Raw message length: {len(message_str)} characters")
197
+ logger.debug(f"Message preview: {message_str[:100]}...")
198
+
199
+ # Ensure the message is properly formatted JSON
200
+ if not message_str:
201
+ raise BalatroError(
202
+ "Empty response received from game",
203
+ error_code="E001",
204
+ context={"raw_data_length": len(complete_message)},
205
+ )
206
+
207
+ response_data = json.loads(message_str)
208
+
209
+ # Check for error response
210
+ if "error" in response_data:
211
+ logger.error(f"API request {name} failed: {response_data.get('error')}")
212
+ raise create_exception_from_error_response(response_data)
213
+
214
+ logger.debug(f"API request {name} completed successfully")
215
+ return response_data
216
+
217
+ except socket.error as e:
218
+ logger.error(f"Socket error during API request {name}: {e}")
219
+ raise ConnectionFailedError(
220
+ f"Socket error during communication: {e}",
221
+ error_code="E008",
222
+ context={"error": str(e)},
223
+ ) from e
224
+ except json.JSONDecodeError as e:
225
+ logger.error(f"Invalid JSON response from API request {name}: {e}")
226
+ logger.error(f"Problematic message content: {message_str[:200]}...")
227
+ logger.error(
228
+ f"Message buffer state: {len(self._message_buffer)} bytes remaining"
229
+ )
230
+
231
+ # Clear the message buffer to prevent cascading errors
232
+ if self._message_buffer:
233
+ logger.warning("Clearing message buffer due to JSON parse error")
234
+ self._message_buffer = b""
235
+
236
+ raise BalatroError(
237
+ f"Invalid JSON response from game: {e}",
238
+ error_code="E001",
239
+ context={"error": str(e), "message_preview": message_str[:100]},
240
+ ) from e
241
+
242
+ # Checkpoint Management Methods
243
+
244
+ def _convert_windows_path_to_linux(self, windows_path: str) -> str:
245
+ """Convert Windows path to Linux Steam Proton path if on Linux.
246
+
247
+ Args:
248
+ windows_path: Windows-style path (e.g., "C:/Users/.../Balatro/3/save.jkr")
249
+
250
+ Returns:
251
+ Converted path for Linux or original path for other platforms
252
+ """
253
+
254
+ if platform.system() == "Linux":
255
+ # Match Windows drive letter and path (e.g., "C:/...", "D:\\...", "E:...")
256
+ match = re.match(r"^([A-Z]):[\\/]*(.*)", windows_path, re.IGNORECASE)
257
+ if match:
258
+ # Replace drive letter with Linux Steam Proton prefix
259
+ linux_prefix = str(
260
+ Path(
261
+ "~/.steam/steam/steamapps/compatdata/2379780/pfx/drive_c"
262
+ ).expanduser()
263
+ )
264
+ # Normalize slashes and join with prefix
265
+ rest_of_path = match.group(2).replace("\\", "/")
266
+ return linux_prefix + "/" + rest_of_path
267
+
268
+ return windows_path
269
+
270
+ def get_save_info(self) -> dict:
271
+ """Get the current save file location and profile information.
272
+
273
+ Development tool for working with save files and checkpoints.
274
+
275
+ Returns:
276
+ Dictionary containing:
277
+ - profile_path: Current profile path (e.g., "3")
278
+ - save_directory: Full path to Love2D save directory
279
+ - save_file_path: Full OS-specific path to save.jkr file
280
+ - has_active_run: Whether a run is currently active
281
+ - save_exists: Whether the save file exists
282
+
283
+ Raises:
284
+ BalatroError: If request fails
285
+
286
+ Note:
287
+ This is primarily for development and testing purposes.
288
+ """
289
+ save_info = self.send_message("get_save_info")
290
+
291
+ # Convert Windows paths to Linux Steam Proton paths if needed
292
+ if "save_file_path" in save_info and save_info["save_file_path"]:
293
+ save_info["save_file_path"] = self._convert_windows_path_to_linux(
294
+ save_info["save_file_path"]
295
+ )
296
+ if "save_directory" in save_info and save_info["save_directory"]:
297
+ save_info["save_directory"] = self._convert_windows_path_to_linux(
298
+ save_info["save_directory"]
299
+ )
300
+
301
+ return save_info
302
+
303
+ def save_checkpoint(self, checkpoint_name: str | Path) -> Path:
304
+ """Save the current save.jkr file as a checkpoint.
305
+
306
+ Args:
307
+ checkpoint_name: Either:
308
+ - A checkpoint name (saved to checkpoints dir)
309
+ - A full file path where the checkpoint should be saved
310
+ - A directory path (checkpoint will be saved as 'save.jkr' inside it)
311
+
312
+ Returns:
313
+ Path to the saved checkpoint file
314
+
315
+ Raises:
316
+ BalatroError: If no save file exists or the destination path is invalid
317
+ IOError: If file operations fail
318
+ """
319
+ # Get current save info
320
+ save_info = self.get_save_info()
321
+ if not save_info.get("save_exists"):
322
+ raise BalatroError(
323
+ "No save file exists to checkpoint", ErrorCode.INVALID_GAME_STATE
324
+ )
325
+
326
+ # Get the full save file path from API (already OS-specific)
327
+ save_path = Path(save_info["save_file_path"])
328
+ if not save_path.exists():
329
+ raise BalatroError(
330
+ f"Save file not found: {save_path}", ErrorCode.MISSING_GAME_OBJECT
331
+ )
332
+
333
+ # Normalize and interpret destination
334
+ dest = Path(checkpoint_name).expanduser()
335
+ # Treat paths without a .jkr suffix as directories
336
+ if dest.suffix.lower() != ".jkr":
337
+ raise BalatroError(
338
+ f"Invalid checkpoint path provided: {dest}",
339
+ ErrorCode.INVALID_PARAMETER,
340
+ context={"path": str(dest), "reason": "Path does not end with .jkr"},
341
+ )
342
+
343
+ # Ensure destination directory exists
344
+ try:
345
+ dest.parent.mkdir(parents=True, exist_ok=True)
346
+ except OSError as e:
347
+ raise BalatroError(
348
+ f"Invalid checkpoint path provided: {dest}",
349
+ ErrorCode.INVALID_PARAMETER,
350
+ context={"path": str(dest), "reason": str(e)},
351
+ ) from e
352
+
353
+ # Copy save file to checkpoint
354
+ try:
355
+ shutil.copy2(save_path, dest)
356
+ except OSError as e:
357
+ raise BalatroError(
358
+ f"Failed to write checkpoint to: {dest}",
359
+ ErrorCode.INVALID_PARAMETER,
360
+ context={"path": str(dest), "reason": str(e)},
361
+ ) from e
362
+
363
+ return dest
364
+
365
+ def prepare_save(self, source_path: str | Path) -> str:
366
+ """Prepare a test save file for use with load_save.
367
+
368
+ This copies a .jkr file from your test directory into Love2D's save directory
369
+ in a temporary profile so it can be loaded with load_save().
370
+
371
+ Args:
372
+ source_path: Path to the .jkr save file to prepare
373
+
374
+ Returns:
375
+ The Love2D-relative path to use with load_save()
376
+ (e.g., "checkpoint/save.jkr")
377
+
378
+ Raises:
379
+ BalatroError: If source file not found
380
+ IOError: If file operations fail
381
+ """
382
+ source = Path(source_path)
383
+ if not source.exists():
384
+ raise BalatroError(
385
+ f"Source save file not found: {source}", ErrorCode.MISSING_GAME_OBJECT
386
+ )
387
+
388
+ # Get save directory info
389
+ save_info = self.get_save_info()
390
+ if not save_info.get("save_directory"):
391
+ raise BalatroError(
392
+ "Cannot determine Love2D save directory", ErrorCode.INVALID_GAME_STATE
393
+ )
394
+
395
+ checkpoints_profile = "checkpoint"
396
+ save_dir = Path(save_info["save_directory"])
397
+ checkpoints_dir = save_dir / checkpoints_profile
398
+ checkpoints_dir.mkdir(parents=True, exist_ok=True)
399
+
400
+ # Copy the save file to the test profile
401
+ dest_path = checkpoints_dir / "save.jkr"
402
+ shutil.copy2(source, dest_path)
403
+
404
+ # Return the Love2D-relative path
405
+ return f"{checkpoints_profile}/save.jkr"
406
+
407
+ def load_save(self, save_path: str | Path) -> dict:
408
+ """Load a save file directly without requiring a game restart.
409
+
410
+ This method loads a save file (in Love2D's save directory format) and starts
411
+ a run from that save state. Unlike load_checkpoint which copies to the profile's
412
+ save location and requires restart, this directly loads the save into the game.
413
+
414
+ This is particularly useful for testing as it allows you to quickly jump to
415
+ specific game states without manual setup.
416
+
417
+ Args:
418
+ save_path: Path to the save file relative to Love2D save directory
419
+ (e.g., "3/save.jkr" for profile 3's save)
420
+
421
+ Returns:
422
+ Game state after loading the save
423
+
424
+ Raises:
425
+ BalatroError: If save file not found or loading fails
426
+
427
+ Note:
428
+ This is a development tool that bypasses normal game flow.
429
+ Use with caution in production bots.
430
+
431
+ Example:
432
+ ```python
433
+ # Load a profile's save directly
434
+ game_state = client.load_save("3/save.jkr")
435
+
436
+ # Or use with prepare_save for external files
437
+ save_path = client.prepare_save("tests/fixtures/shop_state.jkr")
438
+ game_state = client.load_save(save_path)
439
+ ```
440
+ """
441
+ # Convert to string if Path object
442
+ if isinstance(save_path, Path):
443
+ save_path = str(save_path)
444
+
445
+ # Send load_save request to API
446
+ return self.send_message("load_save", {"save_path": save_path})
447
+
448
+ def load_absolute_save(self, save_path: str | Path) -> dict:
449
+ """Load a save from an absolute path. Takes a full path from the OS as a .jkr file and loads it into the game.
450
+
451
+ Args:
452
+ save_path: Path to the save file relative to Love2D save directory
453
+ (e.g., "3/save.jkr" for profile 3's save)
454
+
455
+ Returns:
456
+ Game state after loading the save
457
+ """
458
+ love_save_path = self.prepare_save(save_path)
459
+ return self.load_save(love_save_path)
460
+
461
+ def screenshot(self, path: Path | None = None) -> Path:
462
+ """
463
+ Take a screenshot and save as both PNG and JPEG formats.
464
+
465
+ Args:
466
+ path: Optional path for PNG file. If provided, PNG will be moved to this location.
467
+
468
+ Returns:
469
+ Path to the PNG screenshot. JPEG is saved alongside with .jpg extension.
470
+
471
+ Note:
472
+ The response now includes both 'path' (PNG) and 'jpeg_path' (JPEG) keys.
473
+ This method maintains backward compatibility by returning the PNG path.
474
+ """
475
+ screenshot_response = self.send_message("screenshot", {})
476
+
477
+ if path is None:
478
+ return Path(screenshot_response["path"])
479
+ else:
480
+ source_path = Path(screenshot_response["path"])
481
+ dest_path = path
482
+ shutil.move(source_path, dest_path)
483
+ return dest_path