balatrobot 0.7.4__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 +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 +51 -0
- balatrobot/platforms/base.py +69 -0
- balatrobot/platforms/macos.py +47 -0
- balatrobot/platforms/native.py +111 -0
- balatrobot-1.0.0.dist-info/METADATA +92 -0
- balatrobot-1.0.0.dist-info/RECORD +14 -0
- {balatrobot-0.7.4.dist-info → balatrobot-1.0.0.dist-info}/WHEEL +1 -1
- balatrobot-1.0.0.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/METADATA +0 -55
- balatrobot-0.7.4.dist-info/RECORD +0 -10
- {balatrobot-0.7.4.dist-info → balatrobot-1.0.0.dist-info}/licenses/LICENSE +0 -0
balatrobot/__init__.py
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
"""BalatroBot -
|
|
1
|
+
"""BalatroBot - API for developing Balatro bots."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
4
|
-
from .
|
|
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.
|
|
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
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]
|