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