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.
balatrobot/__init__.py CHANGED
@@ -1,21 +1,7 @@
1
- """BalatroBot - Python client for the BalatroBot game API."""
1
+ """BalatroBot - API for developing Balatro bots."""
2
2
 
3
- from .client import BalatroClient
4
- from .enums import Actions, Decks, Stakes, State
5
- from .exceptions import BalatroError
6
- from .models import G
3
+ from balatrobot.config import Config
4
+ from balatrobot.manager import BalatroInstance
7
5
 
8
- __version__ = "0.7.3"
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
- ]
6
+ __version__ = "1.0.0"
7
+ __all__ = ["BalatroInstance", "Config", "__version__"]
balatrobot/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m balatrobot."""
2
+
3
+ from balatrobot.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
balatrobot/cli.py ADDED
@@ -0,0 +1,56 @@
1
+ """CLI entry point for BalatroBot launcher."""
2
+
3
+ import argparse
4
+ import asyncio
5
+
6
+ from balatrobot.config import Config
7
+ from balatrobot.manager import BalatroInstance
8
+
9
+
10
+ def create_parser() -> argparse.ArgumentParser:
11
+ """Create the argument parser for balatrobot CLI."""
12
+ # fmt: off
13
+ parser = argparse.ArgumentParser(prog="balatrobot", description="Start Balatro with BalatroBot mod loaded")
14
+
15
+ # No defaults - env vars and dataclass defaults handle it
16
+ parser.add_argument("--host", help="Server hostname (default: 127.0.0.1)")
17
+ parser.add_argument("--port", type=int, help="Server port (default: 12346)")
18
+ parser.add_argument("--logs-path", help="Directory for log files (default: logs)")
19
+
20
+ # Boolean flags - store_const so None means "not provided" -> check env var
21
+ parser.add_argument("--fast", action="store_const", const=True, help="Enable fast mode (10x speed)")
22
+ parser.add_argument("--headless", action="store_const", const=True, help="Enable headless mode")
23
+ parser.add_argument("--render-on-api", action="store_const", const=True, help="Render only on API calls")
24
+ parser.add_argument("--audio", action="store_const", const=True, help="Enable audio")
25
+ parser.add_argument("--debug", action="store_const", const=True, help="Enable debug mode")
26
+ parser.add_argument("--no-shaders", action="store_const", const=True, help="Disable shaders")
27
+
28
+ # Path args
29
+ parser.add_argument("--balatro-path", help="Path to Balatro executable")
30
+ parser.add_argument("--lovely-path", help="Path to lovely library")
31
+ parser.add_argument("--love-path", help="Path to LOVE executable")
32
+ parser.add_argument("--platform", choices=["darwin", "linux", "windows", "native"])
33
+ # fmt: on
34
+
35
+ return parser
36
+
37
+
38
+ async def async_main(argv: list[str] | None = None) -> int:
39
+ """Async main entry point."""
40
+ parser = create_parser()
41
+ args = parser.parse_args(argv)
42
+ config = Config.from_args(args)
43
+
44
+ async with BalatroInstance(config) as instance:
45
+ print(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.")
46
+ while True:
47
+ await asyncio.sleep(5)
48
+
49
+
50
+ def main(argv: list[str] | None = None) -> int:
51
+ """Main entry point for balatrobot CLI."""
52
+ try:
53
+ asyncio.run(async_main(argv))
54
+ return 0
55
+ except KeyboardInterrupt:
56
+ return 0
balatrobot/config.py ADDED
@@ -0,0 +1,101 @@
1
+ """Configuration dataclass for BalatroBot launcher."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Any, Self
6
+
7
+ # Mapping: config field -> env var
8
+ ENV_MAP: dict[str, str] = {
9
+ "host": "BALATROBOT_HOST",
10
+ "port": "BALATROBOT_PORT",
11
+ "fast": "BALATROBOT_FAST",
12
+ "headless": "BALATROBOT_HEADLESS",
13
+ "render_on_api": "BALATROBOT_RENDER_ON_API",
14
+ "audio": "BALATROBOT_AUDIO",
15
+ "debug": "BALATROBOT_DEBUG",
16
+ "no_shaders": "BALATROBOT_NO_SHADERS",
17
+ "balatro_path": "BALATROBOT_BALATRO_PATH",
18
+ "lovely_path": "BALATROBOT_LOVELY_PATH",
19
+ "love_path": "BALATROBOT_LOVE_PATH",
20
+ "platform": "BALATROBOT_PLATFORM",
21
+ "logs_path": "BALATROBOT_LOGS_PATH",
22
+ }
23
+
24
+ BOOL_FIELDS = frozenset(
25
+ {"fast", "headless", "render_on_api", "audio", "debug", "no_shaders"}
26
+ )
27
+ INT_FIELDS = frozenset({"port"})
28
+
29
+
30
+ def _parse_env_value(field: str, value: str) -> str | int | bool:
31
+ """Convert env var string to proper type. Raises ValueError on invalid int."""
32
+ if field in BOOL_FIELDS:
33
+ return value in ("1", "true")
34
+ if field in INT_FIELDS:
35
+ return int(value) # Raises ValueError if invalid
36
+ return value
37
+
38
+
39
+ @dataclass
40
+ class Config:
41
+ """Configuration for BalatroBot launcher."""
42
+
43
+ # HTTP
44
+ host: str = "127.0.0.1"
45
+ port: int = 12346
46
+
47
+ # Balatro
48
+ fast: bool = False
49
+ headless: bool = False
50
+ render_on_api: bool = False
51
+ audio: bool = False
52
+ debug: bool = False
53
+ no_shaders: bool = False
54
+
55
+ # Launcher
56
+ balatro_path: str | None = None
57
+ lovely_path: str | None = None
58
+ love_path: str | None = None
59
+
60
+ # Instance
61
+ platform: str | None = None
62
+ logs_path: str = "logs"
63
+
64
+ @classmethod
65
+ def from_args(cls, args) -> Self:
66
+ """Create Config from CLI args with env var fallback."""
67
+ kwargs: dict[str, Any] = {}
68
+
69
+ for field, env_var in ENV_MAP.items():
70
+ cli_val = getattr(args, field, None)
71
+ if cli_val is not None:
72
+ kwargs[field] = cli_val
73
+ elif (env_val := os.environ.get(env_var)) is not None:
74
+ kwargs[field] = _parse_env_value(field, env_val)
75
+
76
+ return cls(**kwargs)
77
+
78
+ @classmethod
79
+ def from_env(cls) -> Self:
80
+ """Create Config from environment variables only."""
81
+ kwargs: dict[str, Any] = {}
82
+
83
+ for field, env_var in ENV_MAP.items():
84
+ if (env_val := os.environ.get(env_var)) is not None:
85
+ kwargs[field] = _parse_env_value(field, env_val)
86
+
87
+ return cls(**kwargs)
88
+
89
+ def to_env(self) -> dict[str, str]:
90
+ """Convert config to environment variables dict."""
91
+ env: dict[str, str] = {}
92
+ for field, env_var in ENV_MAP.items():
93
+ value = getattr(self, field)
94
+ if value is None:
95
+ continue
96
+ if field in BOOL_FIELDS:
97
+ if value:
98
+ env[env_var] = "1"
99
+ else:
100
+ env[env_var] = str(value)
101
+ return env
balatrobot/manager.py ADDED
@@ -0,0 +1,129 @@
1
+ """Context manager for a Balatro instance."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from dataclasses import replace
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from balatrobot.config import Config
12
+ from balatrobot.platforms import get_launcher
13
+
14
+ HEALTH_TIMEOUT = 30.0
15
+
16
+
17
+ class BalatroInstance:
18
+ """Context manager for a single Balatro instance."""
19
+
20
+ def __init__(self, config: Config | None = None, **overrides) -> None:
21
+ """Initialize a Balatro instance.
22
+
23
+ Args:
24
+ config: Base configuration. If None, uses Config from environment.
25
+ **overrides: Override specific config fields (e.g., port=12347).
26
+ """
27
+ base = config or Config.from_env()
28
+ self._config = replace(base, **overrides) if overrides else base
29
+ self._process: subprocess.Popen | None = None
30
+ self._log_path: Path | None = None
31
+
32
+ @property
33
+ def port(self) -> int:
34
+ """Get the port this instance is running on."""
35
+ return self._config.port
36
+
37
+ @property
38
+ def process(self) -> subprocess.Popen:
39
+ """Get the subprocess. Raises if not started."""
40
+ if self._process is None:
41
+ raise RuntimeError("Instance not started")
42
+ return self._process
43
+
44
+ @property
45
+ def log_path(self) -> Path | None:
46
+ """Get the log file path, if available."""
47
+ return self._log_path
48
+
49
+ async def _wait_for_health(self, timeout: float = HEALTH_TIMEOUT) -> None:
50
+ """Wait for health endpoint to respond."""
51
+ url = f"http://{self._config.host}:{self._config.port}"
52
+ payload = {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 1}
53
+ start = asyncio.get_event_loop().time()
54
+
55
+ while asyncio.get_event_loop().time() - start < timeout:
56
+ try:
57
+ async with httpx.AsyncClient(timeout=2.0) as client:
58
+ response = await client.post(url, json=payload)
59
+ data = response.json()
60
+ if "result" in data and data["result"].get("status") == "ok":
61
+ return
62
+ except (httpx.ConnectError, httpx.TimeoutException):
63
+ pass
64
+ await asyncio.sleep(0.5)
65
+
66
+ raise RuntimeError(
67
+ f"Health check failed after {timeout}s on "
68
+ f"{self._config.host}:{self._config.port}"
69
+ )
70
+
71
+ async def start(self) -> None:
72
+ """Start the Balatro instance and wait for health."""
73
+ if self._process is not None:
74
+ raise RuntimeError("Instance already started")
75
+
76
+ # Create session directory
77
+ timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
78
+ session_dir = Path(self._config.logs_path) / timestamp
79
+ session_dir.mkdir(parents=True, exist_ok=True)
80
+ self._log_path = session_dir / f"{self._config.port}.log"
81
+
82
+ # Get launcher and start process
83
+ launcher = get_launcher(self._config.platform)
84
+ print(f"Starting Balatro on port {self._config.port}...")
85
+
86
+ self._process = await launcher.start(self._config, session_dir)
87
+
88
+ # Wait for health
89
+ print(f"Waiting for health check on {self._config.host}:{self._config.port}...")
90
+ try:
91
+ await self._wait_for_health()
92
+ except RuntimeError as e:
93
+ await self.stop()
94
+ raise RuntimeError(f"{e}. Check log file: {self._log_path}") from e
95
+
96
+ print(f"Balatro started (PID: {self._process.pid})")
97
+
98
+ async def stop(self) -> None:
99
+ """Stop the Balatro instance."""
100
+ if self._process is None:
101
+ return
102
+
103
+ process = self._process
104
+ self._process = None
105
+
106
+ print(f"Stopping instance on port {self._config.port}...")
107
+
108
+ # Try graceful termination first
109
+ process.terminate()
110
+
111
+ loop = asyncio.get_running_loop()
112
+ try:
113
+ await asyncio.wait_for(
114
+ loop.run_in_executor(None, process.wait),
115
+ timeout=5,
116
+ )
117
+ except asyncio.TimeoutError:
118
+ print(f"Force killing instance on port {self._config.port}...")
119
+ process.kill()
120
+ await loop.run_in_executor(None, process.wait)
121
+
122
+ async def __aenter__(self) -> "BalatroInstance":
123
+ """Start instance on context entry."""
124
+ await self.start()
125
+ return self
126
+
127
+ async def __aexit__(self, *args) -> None:
128
+ """Stop instance on context exit."""
129
+ await self.stop()
@@ -0,0 +1,51 @@
1
+ """Platform detection and launcher dispatch."""
2
+
3
+ import platform as platform_module
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from balatrobot.platforms.base import BaseLauncher
8
+
9
+ VALID_PLATFORMS = frozenset({"darwin", "linux", "windows", "native"})
10
+
11
+
12
+ def get_launcher(platform: str | None = None) -> "BaseLauncher":
13
+ """Get launcher for the specified or detected platform.
14
+
15
+ Args:
16
+ platform: Optional platform to use instead of auto-detection.
17
+ Valid values: "darwin", "linux", "windows", "native"
18
+
19
+ Returns:
20
+ Launcher instance for the platform
21
+
22
+ Raises:
23
+ RuntimeError: If platform is not supported
24
+ ValueError: If platform is invalid
25
+ """
26
+ # Use override if provided, otherwise auto-detect
27
+ if platform:
28
+ if platform not in VALID_PLATFORMS:
29
+ raise ValueError(
30
+ f"Invalid platform '{platform}'. "
31
+ f"Must be one of: {', '.join(sorted(VALID_PLATFORMS))}"
32
+ )
33
+ system = platform
34
+ else:
35
+ system = platform_module.system().lower()
36
+
37
+ match system:
38
+ case "darwin":
39
+ from balatrobot.platforms.macos import MacOSLauncher
40
+
41
+ return MacOSLauncher()
42
+ case "linux":
43
+ raise NotImplementedError("Linux launcher not yet implemented")
44
+ case "windows":
45
+ raise NotImplementedError("Windows launcher not yet implemented")
46
+ case "native":
47
+ from balatrobot.platforms.native import NativeLauncher
48
+
49
+ return NativeLauncher()
50
+ case _:
51
+ raise RuntimeError(f"Unsupported platform: {system}")
@@ -0,0 +1,69 @@
1
+ """Base launcher class for all platforms."""
2
+
3
+ import subprocess
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+
7
+ from balatrobot.config import Config
8
+
9
+
10
+ class BaseLauncher(ABC):
11
+ """Abstract base class for platform-specific launchers."""
12
+
13
+ @abstractmethod
14
+ def validate_paths(self, config: Config) -> None:
15
+ """Validate paths exist, apply platform defaults if None.
16
+
17
+ Mutates config in-place with platform-specific defaults.
18
+
19
+ Raises:
20
+ RuntimeError: If required paths are missing or invalid.
21
+ """
22
+ ...
23
+
24
+ @abstractmethod
25
+ def build_env(self, config: Config) -> dict[str, str]:
26
+ """Build environment dict for subprocess.
27
+
28
+ Returns:
29
+ Environment dict including os.environ and platform-specific vars.
30
+ """
31
+ ...
32
+
33
+ @abstractmethod
34
+ def build_cmd(self, config: Config) -> list[str]:
35
+ """Build command list for subprocess.
36
+
37
+ Returns:
38
+ Command list suitable for subprocess.Popen.
39
+ """
40
+ ...
41
+
42
+ async def start(self, config: Config, session_dir: Path) -> subprocess.Popen:
43
+ """Start Balatro with the given configuration.
44
+
45
+ Args:
46
+ config: Launcher configuration (mutated with defaults).
47
+ session_dir: Directory for log files.
48
+
49
+ Returns:
50
+ The subprocess.Popen object.
51
+
52
+ Raises:
53
+ RuntimeError: If startup fails.
54
+ """
55
+ self.validate_paths(config)
56
+ env = self.build_env(config)
57
+ cmd = self.build_cmd(config)
58
+
59
+ log_path = session_dir / f"{config.port}.log"
60
+
61
+ with open(log_path, "w") as log:
62
+ process = subprocess.Popen(
63
+ cmd,
64
+ env=env,
65
+ stdout=log,
66
+ stderr=subprocess.STDOUT,
67
+ )
68
+
69
+ return process
@@ -0,0 +1,47 @@
1
+ """macOS platform launcher."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from balatrobot.config import Config
7
+ from balatrobot.platforms.base import BaseLauncher
8
+
9
+
10
+ class MacOSLauncher(BaseLauncher):
11
+ """macOS-specific Balatro launcher."""
12
+
13
+ def validate_paths(self, config: Config) -> None:
14
+ """Validate paths, apply macOS defaults if None."""
15
+ if config.love_path is None:
16
+ config.love_path = str(
17
+ Path.home()
18
+ / "Library/Application Support/Steam/steamapps/common/Balatro"
19
+ / "Balatro.app/Contents/MacOS/love"
20
+ )
21
+ if config.lovely_path is None:
22
+ config.lovely_path = str(
23
+ Path.home()
24
+ / "Library/Application Support/Steam/steamapps/common/Balatro"
25
+ / "liblovely.dylib"
26
+ )
27
+
28
+ love = Path(config.love_path)
29
+ lovely = Path(config.lovely_path)
30
+
31
+ if not love.exists():
32
+ raise RuntimeError(f"LOVE executable not found: {love}")
33
+ if not lovely.exists():
34
+ raise RuntimeError(f"liblovely.dylib not found: {lovely}")
35
+
36
+ def build_env(self, config: Config) -> dict[str, str]:
37
+ """Build environment with DYLD_INSERT_LIBRARIES."""
38
+ assert config.lovely_path is not None
39
+ env = os.environ.copy()
40
+ env["DYLD_INSERT_LIBRARIES"] = config.lovely_path
41
+ env.update(config.to_env())
42
+ return env
43
+
44
+ def build_cmd(self, config: Config) -> list[str]:
45
+ """Build macOS launch command."""
46
+ assert config.love_path is not None
47
+ return [config.love_path]
@@ -0,0 +1,111 @@
1
+ """Native LOVE launcher for Linux environments."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from balatrobot.config import Config
9
+ from balatrobot.platforms.base import BaseLauncher
10
+
11
+
12
+ def _detect_love_path() -> Path | None:
13
+ """Detect LOVE executable in PATH."""
14
+ found = shutil.which("love")
15
+ return Path(found) if found else None
16
+
17
+
18
+ def _detect_lovely_path() -> Path | None:
19
+ """Detect liblovely.so in standard locations."""
20
+ candidates = [
21
+ Path("/usr/local/lib/liblovely.so"),
22
+ Path.home() / ".local/lib/liblovely.so",
23
+ ]
24
+ for candidate in candidates:
25
+ if candidate.is_file():
26
+ return candidate
27
+ return None
28
+
29
+
30
+ class NativeLauncher(BaseLauncher):
31
+ """Native LOVE launcher using LD_PRELOAD injection (Linux only).
32
+
33
+ This launcher is designed for:
34
+ - Docker containers with LOVE installed
35
+ - Linux development environments with native LOVE
36
+
37
+ Requirements:
38
+ - Linux operating system
39
+ - `love` executable in PATH or specified via --love-path
40
+ - liblovely.so specified via --lovely-path
41
+ - Game directory specified via --balatro-path
42
+ """
43
+
44
+ def validate_paths(self, config: Config) -> None:
45
+ """Validate and auto-detect paths for native Linux launcher."""
46
+ if platform.system().lower() != "linux":
47
+ raise RuntimeError("Native launcher is only supported on Linux")
48
+
49
+ errors: list[str] = []
50
+
51
+ # balatro_path (required, no auto-detect)
52
+ if config.balatro_path is None:
53
+ errors.append(
54
+ "Game directory is required.\n"
55
+ " Set via: --balatro-path or BALATROBOT_BALATRO_PATH"
56
+ )
57
+ else:
58
+ balatro = Path(config.balatro_path)
59
+ if not balatro.is_dir():
60
+ errors.append(f"Game directory not found: {balatro}")
61
+
62
+ # lovely_path (required, auto-detect)
63
+ if config.lovely_path is None:
64
+ detected = _detect_lovely_path()
65
+ if detected:
66
+ config.lovely_path = str(detected)
67
+ else:
68
+ errors.append(
69
+ "Lovely library is required.\n"
70
+ " Set via: --lovely-path or BALATROBOT_LOVELY_PATH\n"
71
+ " Expected: /usr/local/lib/liblovely.so"
72
+ )
73
+ if config.lovely_path:
74
+ lovely = Path(config.lovely_path)
75
+ if not lovely.is_file():
76
+ errors.append(f"Lovely library not found: {lovely}")
77
+ elif lovely.suffix != ".so":
78
+ errors.append(f"Lovely library has wrong extension: {lovely}")
79
+
80
+ # love_path (required, auto-detect via PATH)
81
+ if config.love_path is None:
82
+ detected = _detect_love_path()
83
+ if detected:
84
+ config.love_path = str(detected)
85
+ else:
86
+ errors.append(
87
+ "LOVE executable is required.\n"
88
+ " Set via: --love-path or BALATROBOT_LOVE_PATH\n"
89
+ " Or install love and ensure it's in PATH"
90
+ )
91
+ if config.love_path:
92
+ love = Path(config.love_path)
93
+ if not love.is_file():
94
+ errors.append(f"LOVE executable not found: {love}")
95
+
96
+ if errors:
97
+ raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors))
98
+
99
+ def build_env(self, config: Config) -> dict[str, str]:
100
+ """Build environment with LD_PRELOAD."""
101
+ assert config.lovely_path is not None
102
+ env = os.environ.copy()
103
+ env["LD_PRELOAD"] = config.lovely_path
104
+ env.update(config.to_env())
105
+ return env
106
+
107
+ def build_cmd(self, config: Config) -> list[str]:
108
+ """Build native LOVE launch command."""
109
+ assert config.love_path is not None
110
+ assert config.balatro_path is not None
111
+ return [config.love_path, config.balatro_path]