balatrobot 0.7.3__py3-none-any.whl → 1.0.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.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: balatrobot
3
+ Version: 1.0.0
4
+ Summary: API for developing Balatro bots
5
+ Project-URL: Homepage, https://github.com/coder/balatrobot
6
+ Project-URL: Issues, https://github.com/coder/balatrobot/issues
7
+ Project-URL: Repository, https://github.com/coder/balatrobot
8
+ Project-URL: Changelog, https://github.com/coder/balatrobot/blob/main/CHANGELOG.md
9
+ Author: stirby, giewev, besteon, phughesion
10
+ Author-email: S1M0N38 <bertolottosimone@gmail.com>
11
+ License-File: LICENSE
12
+ Classifier: Framework :: Pytest
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.13
17
+ Requires-Dist: httpx>=0.28.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ <div align="center">
21
+ <h1>BalatroBot</h1>
22
+ <p align="center">
23
+ <a href="https://github.com/coder/balatrobot/releases">
24
+ <img alt="GitHub release" src="https://img.shields.io/github/v/release/coder/balatrobot?include_prereleases&sort=semver&style=for-the-badge&logo=github"/>
25
+ </a>
26
+ <a href="https://discord.gg/TPn6FYgGPv">
27
+ <img alt="Discord" src="https://img.shields.io/badge/discord-server?style=for-the-badge&logo=discord&logoColor=%23FFFFFF&color=%235865F2"/>
28
+ </a>
29
+ </p>
30
+ <div><img src="./docs/assets/balatrobot.svg" alt="balatrobot" width="170" height="170"></div>
31
+ <p><em>API for developing Balatro bots</em></p>
32
+ </div>
33
+
34
+ ---
35
+
36
+ BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing game state and controls for external program interaction. The API provides endpoints for complete game control, including card selection, shop transactions, blind selection, and state management. External clients connect via HTTP POST to execute game actions programmatically.
37
+
38
+ > [!WARNING]
39
+ > **BalatroBot 1.0.0 introduces breaking changes:**
40
+ >
41
+ > - Now a CLI to start Balatro (no longer a Python client)
42
+ > - New JSON-RPC 2.0 protocol over HTTP/1.1
43
+ > - Updated endpoints and API structure
44
+ > - Removed game state logging functionality
45
+ >
46
+ > BalatroBot is now a Lua mod that exposes an API for programmatic game control.
47
+
48
+ ## 📚 Documentation
49
+
50
+ https://coder.github.io/balatrobot/
51
+
52
+ ## 🙏 Acknowledgments
53
+
54
+ This project is a fork of the original [balatrobot](https://github.com/besteon/balatrobot) repository. We would like to acknowledge and thank the original contributors who laid the foundation for this framework:
55
+
56
+ - [@phughesion](https://github.com/phughesion)
57
+ - [@besteon](https://github.com/besteon)
58
+ - [@giewev](https://github.com/giewev)
59
+
60
+ The original repository provided the initial API and botting framework that this project has evolved from. We appreciate their work in creating the foundation for Balatro bot development.
61
+
62
+ ## 🚀 Related Projects
63
+
64
+ <div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;">
65
+ <figure style="text-align: center; margin: 0;">
66
+ <a href="https://coder.github.io/balatrobot/">
67
+ <img src="docs/assets/balatrobot.svg" alt="BalatroBot" width="92">
68
+ </a>
69
+ <figcaption>
70
+ <a href="https://coder.github.io/balatrobot/">BalatroBot</a><br>
71
+ <small>API for developing Balatro bots</small>
72
+ </figcaption>
73
+ </figure>
74
+ <figure style="text-align: center; margin: 0;">
75
+ <a href="https://coder.github.io/balatrollm/">
76
+ <img src="docs/assets/balatrollm.svg" alt="BalatroLLM" width="92">
77
+ </a>
78
+ <figcaption>
79
+ <a href="https://coder.github.io/balatrollm/">BalatroLLM</a><br>
80
+ <small>Play Balatro with LLMs</small>
81
+ </figcaption>
82
+ </figure>
83
+ <figure style="text-align: center; margin: 0;">
84
+ <a href="https://coder.github.io/balatrobench/">
85
+ <img src="docs/assets/balatrobench.svg" alt="BalatroBench" width="92">
86
+ </a>
87
+ <figcaption>
88
+ <a href="https://coder.github.io/balatrobench/">BalatroBench</a><br>
89
+ <small>Benchmark LLMs playing Balatro</small>
90
+ </figcaption>
91
+ </figure>
92
+ </div>
@@ -0,0 +1,14 @@
1
+ balatrobot/__init__.py,sha256=RctuAtU5oDkld946Zx4LAlCNoE7LKfWgNof_P4gMHQ0,215
2
+ balatrobot/__main__.py,sha256=HujoMPXZ1c2H9ECC6bXY5jmqy4pxlzFGD9U_hnAlSS4,134
3
+ balatrobot/cli.py,sha256=R5xzSWQzX2ONK_B-s6s2d6fY_fUNsH06dX5BEolmUx0,2339
4
+ balatrobot/config.py,sha256=7Ib4HYUEHpAqLn5ua4oRnmXWsFr1D-YILnR0gwxdCkI,2966
5
+ balatrobot/manager.py,sha256=hiMAFN7V16Bt0gTs_6WuBUZtpwHqCi_WSZx1sdCk6_E,4433
6
+ balatrobot/platforms/__init__.py,sha256=EiRON9FKkErzJF1E405D9COD5PrWkPCsG_FJbMOYpr4,1627
7
+ balatrobot/platforms/base.py,sha256=1mW1gwiyY8oXM0mhCDB6qC7e6O-kun-PzaED6a6umRU,1842
8
+ balatrobot/platforms/macos.py,sha256=bYwlpfWKAUpXsGdQFip-hq0D8m6xy6UjiE76FpKyyYY,1601
9
+ balatrobot/platforms/native.py,sha256=f5znPufm49LHKJrpeNBQLR80Vr2KnVN9CuQwjnwJuCY,3896
10
+ balatrobot-1.0.0.dist-info/METADATA,sha256=50SP88g62bOm52q647QoT8P7QGfw5e2ZlJwY-ai8JfM,3898
11
+ balatrobot-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ balatrobot-1.0.0.dist-info/entry_points.txt,sha256=TGBKXiQ57THrsIfLfz9vZgufbuwgJDLJsorJpxadmIM,51
13
+ balatrobot-1.0.0.dist-info/licenses/LICENSE,sha256=71EXhU7CSe-Cihhj_VVxLtgVnSOaavHqVoixPKtE7Bk,1064
14
+ balatrobot-1.0.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ balatrobot = balatrobot.cli:main
balatrobot/client.py DELETED
@@ -1,485 +0,0 @@
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 = 300.0
39
- buffer_size = 65536
40
-
41
- def __init__(self, port: int = 12346, timeout: float | None = None):
42
- """Initialize BalatroBot client
43
-
44
- Args:
45
- port: Port number to connect to (default: 12346)
46
- timeout: Socket timeout in seconds (default: 300.0)
47
- """
48
- self.port = port
49
- self.timeout = timeout if timeout is not None else self.timeout
50
- self._socket: socket.socket | None = None
51
- self._connected = False
52
- self._message_buffer = b"" # Buffer for incomplete messages
53
-
54
- def _receive_complete_message(self) -> bytes:
55
- """Receive a complete message from the socket, handling message boundaries properly."""
56
- if not self._connected or not self._socket:
57
- raise ConnectionFailedError(
58
- "Socket not connected",
59
- error_code="E008",
60
- context={
61
- "connected": self._connected,
62
- "socket": self._socket is not None,
63
- },
64
- )
65
-
66
- # Check if we already have a complete message in the buffer
67
- while b"\n" not in self._message_buffer:
68
- try:
69
- chunk = self._socket.recv(self.buffer_size)
70
- except socket.timeout:
71
- raise ConnectionFailedError(
72
- "Socket timeout while receiving data",
73
- error_code="E008",
74
- context={
75
- "timeout": self.timeout,
76
- "buffer_size": len(self._message_buffer),
77
- },
78
- )
79
- except socket.error as e:
80
- raise ConnectionFailedError(
81
- f"Socket error while receiving: {e}",
82
- error_code="E008",
83
- context={"error": str(e), "buffer_size": len(self._message_buffer)},
84
- )
85
-
86
- if not chunk:
87
- raise ConnectionFailedError(
88
- "Connection closed by server",
89
- error_code="E008",
90
- context={"buffer_size": len(self._message_buffer)},
91
- )
92
- self._message_buffer += chunk
93
-
94
- # Extract the first complete message
95
- message_end = self._message_buffer.find(b"\n")
96
- complete_message = self._message_buffer[:message_end]
97
-
98
- # Update buffer to remove the processed message
99
- remaining_data = self._message_buffer[message_end + 1 :]
100
- self._message_buffer = remaining_data
101
-
102
- # Log any remaining data for debugging
103
- if remaining_data:
104
- logger.warning(f"Data remaining in buffer: {len(remaining_data)} bytes")
105
- logger.debug(f"Buffer preview: {remaining_data[:100]}...")
106
-
107
- return complete_message
108
-
109
- def __enter__(self) -> Self:
110
- """Enter context manager and connect to the game."""
111
- self.connect()
112
- return self
113
-
114
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
115
- """Exit context manager and disconnect from the game."""
116
- self.disconnect()
117
-
118
- def connect(self) -> None:
119
- """Connect to Balatro TCP server
120
-
121
- Raises:
122
- ConnectionFailedError: If not connected to the game
123
- """
124
- if self._connected:
125
- return
126
-
127
- logger.info(f"Connecting to BalatroBot API at {self.host}:{self.port}")
128
- try:
129
- self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
130
- self._socket.settimeout(self.timeout)
131
- self._socket.setsockopt(
132
- socket.SOL_SOCKET, socket.SO_RCVBUF, self.buffer_size
133
- )
134
- self._socket.connect((self.host, self.port))
135
- self._connected = True
136
- logger.info(
137
- f"Successfully connected to BalatroBot API at {self.host}:{self.port}"
138
- )
139
- except (socket.error, OSError) as e:
140
- logger.error(f"Failed to connect to {self.host}:{self.port}: {e}")
141
- raise ConnectionFailedError(
142
- f"Failed to connect to {self.host}:{self.port}",
143
- error_code="E008",
144
- context={"host": self.host, "port": self.port, "error": str(e)},
145
- ) from e
146
-
147
- def disconnect(self) -> None:
148
- """Disconnect from the BalatroBot game API."""
149
- if self._socket:
150
- logger.info(f"Disconnecting from BalatroBot API at {self.host}:{self.port}")
151
- self._socket.close()
152
- self._socket = None
153
- self._connected = False
154
- # Clear message buffer on disconnect
155
- self._message_buffer = b""
156
-
157
- def send_message(self, name: str, arguments: dict | None = None) -> dict:
158
- """Send JSON message to Balatro and receive response
159
-
160
- Args:
161
- name: Function name to call
162
- arguments: Function arguments
163
-
164
- Returns:
165
- Response from the game API
166
-
167
- Raises:
168
- ConnectionFailedError: If not connected to the game
169
- BalatroError: If the API returns an error
170
- """
171
- if arguments is None:
172
- arguments = {}
173
-
174
- if not self._connected or not self._socket:
175
- raise ConnectionFailedError(
176
- "Not connected to the game API",
177
- error_code="E008",
178
- context={
179
- "connected": self._connected,
180
- "socket": self._socket is not None,
181
- },
182
- )
183
-
184
- # Create and validate request
185
- request = APIRequest(name=name, arguments=arguments)
186
- logger.debug(f"Sending API request: {name}")
187
-
188
- try:
189
- # Send request
190
- message = request.model_dump_json() + "\n"
191
- self._socket.send(message.encode())
192
-
193
- # Receive response using improved message handling
194
- complete_message = self._receive_complete_message()
195
-
196
- # Decode and validate the message
197
- message_str = complete_message.decode().strip()
198
- logger.debug(f"Raw message length: {len(message_str)} characters")
199
- logger.debug(f"Message preview: {message_str[:100]}...")
200
-
201
- # Ensure the message is properly formatted JSON
202
- if not message_str:
203
- raise BalatroError(
204
- "Empty response received from game",
205
- error_code="E001",
206
- context={"raw_data_length": len(complete_message)},
207
- )
208
-
209
- response_data = json.loads(message_str)
210
-
211
- # Check for error response
212
- if "error" in response_data:
213
- logger.error(f"API request {name} failed: {response_data.get('error')}")
214
- raise create_exception_from_error_response(response_data)
215
-
216
- logger.debug(f"API request {name} completed successfully")
217
- return response_data
218
-
219
- except socket.error as e:
220
- logger.error(f"Socket error during API request {name}: {e}")
221
- raise ConnectionFailedError(
222
- f"Socket error during communication: {e}",
223
- error_code="E008",
224
- context={"error": str(e)},
225
- ) from e
226
- except json.JSONDecodeError as e:
227
- logger.error(f"Invalid JSON response from API request {name}: {e}")
228
- logger.error(f"Problematic message content: {message_str[:200]}...")
229
- logger.error(
230
- f"Message buffer state: {len(self._message_buffer)} bytes remaining"
231
- )
232
-
233
- # Clear the message buffer to prevent cascading errors
234
- if self._message_buffer:
235
- logger.warning("Clearing message buffer due to JSON parse error")
236
- self._message_buffer = b""
237
-
238
- raise BalatroError(
239
- f"Invalid JSON response from game: {e}",
240
- error_code="E001",
241
- context={"error": str(e), "message_preview": message_str[:100]},
242
- ) from e
243
-
244
- # Checkpoint Management Methods
245
-
246
- def _convert_windows_path_to_linux(self, windows_path: str) -> str:
247
- """Convert Windows path to Linux Steam Proton path if on Linux.
248
-
249
- Args:
250
- windows_path: Windows-style path (e.g., "C:/Users/.../Balatro/3/save.jkr")
251
-
252
- Returns:
253
- Converted path for Linux or original path for other platforms
254
- """
255
-
256
- if platform.system() == "Linux":
257
- # Match Windows drive letter and path (e.g., "C:/...", "D:\\...", "E:...")
258
- match = re.match(r"^([A-Z]):[\\/]*(.*)", windows_path, re.IGNORECASE)
259
- if match:
260
- # Replace drive letter with Linux Steam Proton prefix
261
- linux_prefix = str(
262
- Path(
263
- "~/.steam/steam/steamapps/compatdata/2379780/pfx/drive_c"
264
- ).expanduser()
265
- )
266
- # Normalize slashes and join with prefix
267
- rest_of_path = match.group(2).replace("\\", "/")
268
- return linux_prefix + "/" + rest_of_path
269
-
270
- return windows_path
271
-
272
- def get_save_info(self) -> dict:
273
- """Get the current save file location and profile information.
274
-
275
- Development tool for working with save files and checkpoints.
276
-
277
- Returns:
278
- Dictionary containing:
279
- - profile_path: Current profile path (e.g., "3")
280
- - save_directory: Full path to Love2D save directory
281
- - save_file_path: Full OS-specific path to save.jkr file
282
- - has_active_run: Whether a run is currently active
283
- - save_exists: Whether the save file exists
284
-
285
- Raises:
286
- BalatroError: If request fails
287
-
288
- Note:
289
- This is primarily for development and testing purposes.
290
- """
291
- save_info = self.send_message("get_save_info")
292
-
293
- # Convert Windows paths to Linux Steam Proton paths if needed
294
- if "save_file_path" in save_info and save_info["save_file_path"]:
295
- save_info["save_file_path"] = self._convert_windows_path_to_linux(
296
- save_info["save_file_path"]
297
- )
298
- if "save_directory" in save_info and save_info["save_directory"]:
299
- save_info["save_directory"] = self._convert_windows_path_to_linux(
300
- save_info["save_directory"]
301
- )
302
-
303
- return save_info
304
-
305
- def save_checkpoint(self, checkpoint_name: str | Path) -> Path:
306
- """Save the current save.jkr file as a checkpoint.
307
-
308
- Args:
309
- checkpoint_name: Either:
310
- - A checkpoint name (saved to checkpoints dir)
311
- - A full file path where the checkpoint should be saved
312
- - A directory path (checkpoint will be saved as 'save.jkr' inside it)
313
-
314
- Returns:
315
- Path to the saved checkpoint file
316
-
317
- Raises:
318
- BalatroError: If no save file exists or the destination path is invalid
319
- IOError: If file operations fail
320
- """
321
- # Get current save info
322
- save_info = self.get_save_info()
323
- if not save_info.get("save_exists"):
324
- raise BalatroError(
325
- "No save file exists to checkpoint", ErrorCode.INVALID_GAME_STATE
326
- )
327
-
328
- # Get the full save file path from API (already OS-specific)
329
- save_path = Path(save_info["save_file_path"])
330
- if not save_path.exists():
331
- raise BalatroError(
332
- f"Save file not found: {save_path}", ErrorCode.MISSING_GAME_OBJECT
333
- )
334
-
335
- # Normalize and interpret destination
336
- dest = Path(checkpoint_name).expanduser()
337
- # Treat paths without a .jkr suffix as directories
338
- if dest.suffix.lower() != ".jkr":
339
- raise BalatroError(
340
- f"Invalid checkpoint path provided: {dest}",
341
- ErrorCode.INVALID_PARAMETER,
342
- context={"path": str(dest), "reason": "Path does not end with .jkr"},
343
- )
344
-
345
- # Ensure destination directory exists
346
- try:
347
- dest.parent.mkdir(parents=True, exist_ok=True)
348
- except OSError as e:
349
- raise BalatroError(
350
- f"Invalid checkpoint path provided: {dest}",
351
- ErrorCode.INVALID_PARAMETER,
352
- context={"path": str(dest), "reason": str(e)},
353
- ) from e
354
-
355
- # Copy save file to checkpoint
356
- try:
357
- shutil.copy2(save_path, dest)
358
- except OSError as e:
359
- raise BalatroError(
360
- f"Failed to write checkpoint to: {dest}",
361
- ErrorCode.INVALID_PARAMETER,
362
- context={"path": str(dest), "reason": str(e)},
363
- ) from e
364
-
365
- return dest
366
-
367
- def prepare_save(self, source_path: str | Path) -> str:
368
- """Prepare a test save file for use with load_save.
369
-
370
- This copies a .jkr file from your test directory into Love2D's save directory
371
- in a temporary profile so it can be loaded with load_save().
372
-
373
- Args:
374
- source_path: Path to the .jkr save file to prepare
375
-
376
- Returns:
377
- The Love2D-relative path to use with load_save()
378
- (e.g., "checkpoint/save.jkr")
379
-
380
- Raises:
381
- BalatroError: If source file not found
382
- IOError: If file operations fail
383
- """
384
- source = Path(source_path)
385
- if not source.exists():
386
- raise BalatroError(
387
- f"Source save file not found: {source}", ErrorCode.MISSING_GAME_OBJECT
388
- )
389
-
390
- # Get save directory info
391
- save_info = self.get_save_info()
392
- if not save_info.get("save_directory"):
393
- raise BalatroError(
394
- "Cannot determine Love2D save directory", ErrorCode.INVALID_GAME_STATE
395
- )
396
-
397
- checkpoints_profile = "checkpoint"
398
- save_dir = Path(save_info["save_directory"])
399
- checkpoints_dir = save_dir / checkpoints_profile
400
- checkpoints_dir.mkdir(parents=True, exist_ok=True)
401
-
402
- # Copy the save file to the test profile
403
- dest_path = checkpoints_dir / "save.jkr"
404
- shutil.copy2(source, dest_path)
405
-
406
- # Return the Love2D-relative path
407
- return f"{checkpoints_profile}/save.jkr"
408
-
409
- def load_save(self, save_path: str | Path) -> dict:
410
- """Load a save file directly without requiring a game restart.
411
-
412
- This method loads a save file (in Love2D's save directory format) and starts
413
- a run from that save state. Unlike load_checkpoint which copies to the profile's
414
- save location and requires restart, this directly loads the save into the game.
415
-
416
- This is particularly useful for testing as it allows you to quickly jump to
417
- specific game states without manual setup.
418
-
419
- Args:
420
- save_path: Path to the save file relative to Love2D save directory
421
- (e.g., "3/save.jkr" for profile 3's save)
422
-
423
- Returns:
424
- Game state after loading the save
425
-
426
- Raises:
427
- BalatroError: If save file not found or loading fails
428
-
429
- Note:
430
- This is a development tool that bypasses normal game flow.
431
- Use with caution in production bots.
432
-
433
- Example:
434
- ```python
435
- # Load a profile's save directly
436
- game_state = client.load_save("3/save.jkr")
437
-
438
- # Or use with prepare_save for external files
439
- save_path = client.prepare_save("tests/fixtures/shop_state.jkr")
440
- game_state = client.load_save(save_path)
441
- ```
442
- """
443
- # Convert to string if Path object
444
- if isinstance(save_path, Path):
445
- save_path = str(save_path)
446
-
447
- # Send load_save request to API
448
- return self.send_message("load_save", {"save_path": save_path})
449
-
450
- def load_absolute_save(self, save_path: str | Path) -> dict:
451
- """Load a save from an absolute path. Takes a full path from the OS as a .jkr file and loads it into the game.
452
-
453
- Args:
454
- save_path: Path to the save file relative to Love2D save directory
455
- (e.g., "3/save.jkr" for profile 3's save)
456
-
457
- Returns:
458
- Game state after loading the save
459
- """
460
- love_save_path = self.prepare_save(save_path)
461
- return self.load_save(love_save_path)
462
-
463
- def screenshot(self, path: Path | None = None) -> Path:
464
- """
465
- Take a screenshot and save as both PNG and JPEG formats.
466
-
467
- Args:
468
- path: Optional path for PNG file. If provided, PNG will be moved to this location.
469
-
470
- Returns:
471
- Path to the PNG screenshot. JPEG is saved alongside with .jpg extension.
472
-
473
- Note:
474
- The response now includes both 'path' (PNG) and 'jpeg_path' (JPEG) keys.
475
- This method maintains backward compatibility by returning the PNG path.
476
- """
477
- screenshot_response = self.send_message("screenshot", {})
478
-
479
- if path is None:
480
- return Path(screenshot_response["path"])
481
- else:
482
- source_path = Path(screenshot_response["path"])
483
- dest_path = path
484
- shutil.move(source_path, dest_path)
485
- return dest_path