From 6b117d900f392ed7105fb72d48f7ce9ca7c60a65 Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Sat, 28 Mar 2026 03:28:27 -0400 Subject: [PATCH 01/21] feat(platforms): add Linux launcher via Steam Proton Implements LinuxLauncher that auto-detects Steam root, Balatro.exe, Proton installation, version.dll (Lovely), and Wine prefix (compatdata). Sets WINEDLLOVERRIDES="version=n,b" so Wine loads the native Lovely injector instead of its built-in version.dll. Co-Authored-By: Claude Opus 4.6 --- src/balatrobot/platforms/__init__.py | 4 +- src/balatrobot/platforms/linux.py | 227 +++++++++++++++++++++ tests/cli/test_platforms.py | 284 ++++++++++++++++++++++++++- 3 files changed, 510 insertions(+), 5 deletions(-) create mode 100644 src/balatrobot/platforms/linux.py diff --git a/src/balatrobot/platforms/__init__.py b/src/balatrobot/platforms/__init__.py index aa8a560c..15d75d72 100644 --- a/src/balatrobot/platforms/__init__.py +++ b/src/balatrobot/platforms/__init__.py @@ -40,7 +40,9 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": return MacOSLauncher() case "linux": - raise NotImplementedError("Linux launcher not yet implemented") + from balatrobot.platforms.linux import LinuxLauncher + + return LinuxLauncher() case "windows": from balatrobot.platforms.windows import WindowsLauncher diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py new file mode 100644 index 00000000..941a011c --- /dev/null +++ b/src/balatrobot/platforms/linux.py @@ -0,0 +1,227 @@ +"""Linux platform launcher via Steam Proton.""" + +import os +import platform +import re +from pathlib import Path + +from balatrobot.config import Config +from balatrobot.platforms.base import BaseLauncher + +BALATRO_APP_ID = "2379780" + +_STEAM_ROOT_CANDIDATES = [ + Path.home() / ".local/share/Steam", + Path.home() / ".steam/steam", +] + + +def _parse_library_folders(vdf_path: Path) -> list[Path]: + """Extract library folder paths from libraryfolders.vdf.""" + paths: list[Path] = [] + content = vdf_path.read_text() + for match in re.finditer(r'"path"\s+"([^"]+)"', content): + paths.append(Path(match.group(1))) + return paths + + +def _detect_steam_root() -> Path | None: + """Detect the primary Steam installation directory.""" + for candidate in _STEAM_ROOT_CANDIDATES: + if candidate.is_dir() and (candidate / "steamapps").is_dir(): + return candidate.resolve() + return None + + +def _detect_steam_libraries(steam_root: Path) -> list[Path]: + """Return all Steam library steamapps directories. + + Parses libraryfolders.vdf to find additional library folders beyond + the default. Always includes the primary steam_root. + """ + seen: set[Path] = set() + libraries: list[Path] = [] + + def _add(steamapps: Path) -> None: + resolved = steamapps.resolve() + if resolved not in seen and resolved.is_dir(): + seen.add(resolved) + libraries.append(resolved) + + _add(steam_root / "steamapps") + + vdf = steam_root / "steamapps" / "libraryfolders.vdf" + if vdf.is_file(): + for folder in _parse_library_folders(vdf): + _add(folder / "steamapps") + + return libraries + + +def _find_balatro(libraries: list[Path]) -> Path | None: + """Find Balatro.exe in any Steam library.""" + for lib in libraries: + exe = lib / "common" / "Balatro" / "Balatro.exe" + if exe.is_file(): + return exe + return None + + +def _parse_proton_version(name: str) -> tuple[int, ...] | None: + """Extract version tuple from a Proton directory name. + + Returns (major, minor) for names like "Proton 10.0", None for + non-versioned names like "Proton - Experimental". + """ + m = re.match(r"Proton (\d+(?:\.\d+)*)$", name) + if m: + return tuple(int(x) for x in m.group(1).split(".")) + return None + + +def _find_proton(libraries: list[Path]) -> Path | None: + """Find the latest stable Proton installation across all libraries.""" + candidates: list[tuple[tuple[int, ...], Path]] = [] + + for lib in libraries: + common = lib / "common" + if not common.is_dir(): + continue + for entry in common.iterdir(): + if not entry.name.startswith("Proton") or not entry.is_dir(): + continue + if not (entry / "proton").is_file(): + continue + version = _parse_proton_version(entry.name) + if version is not None: + candidates.append((version, entry)) + + if candidates: + candidates.sort(key=lambda c: c[0], reverse=True) + return candidates[0][1] + + # Fall back to Proton - Experimental + for lib in libraries: + experimental = lib / "common" / "Proton - Experimental" + if experimental.is_dir() and (experimental / "proton").is_file(): + return experimental + + return None + + +def _find_compat_data(libraries: list[Path]) -> Path | None: + """Find the Balatro Wine prefix (compatdata) in any library.""" + for lib in libraries: + compat = lib / "compatdata" / BALATRO_APP_ID + if compat.is_dir(): + return compat + return None + + +class LinuxLauncher(BaseLauncher): + """Linux-specific Balatro launcher via Steam Proton. + + Runs Balatro.exe through Proton's Wine compatibility layer. Lovely + injection works via version.dll, the same mechanism as on Windows. + + Auto-detects: + - Steam root (~/.local/share/Steam or ~/.steam/steam) + - Balatro.exe across all Steam library folders + - Latest stable Proton version + - version.dll (lovely) in the Balatro directory + - Wine prefix (compatdata) for Balatro + """ + + def __init__(self) -> None: + self._steam_root: Path | None = None + self._proton_dir: Path | None = None + self._compat_data: Path | None = None + + def validate_paths(self, config: Config) -> None: + """Validate and auto-detect paths for Linux Proton launcher.""" + if platform.system().lower() != "linux": + raise RuntimeError("Linux launcher is only supported on Linux") + + errors: list[str] = [] + + # Steam root + self._steam_root = _detect_steam_root() + if self._steam_root is None: + errors.append( + "Steam installation not found.\n" + " Expected: ~/.local/share/Steam or ~/.steam/steam" + ) + raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) + + libraries = _detect_steam_libraries(self._steam_root) + + # love_path → Balatro.exe + if config.love_path is None: + exe = _find_balatro(libraries) + if exe: + config.love_path = str(exe) + else: + errors.append( + "Balatro not found in Steam library.\n" + " Set via: --love-path or BALATROBOT_LOVE_PATH\n" + " Install Balatro through Steam" + ) + if config.love_path and not Path(config.love_path).is_file(): + errors.append(f"Balatro executable not found: {config.love_path}") + + # lovely_path → version.dll + if config.lovely_path is None and config.love_path: + dll = Path(config.love_path).parent / "version.dll" + if dll.is_file(): + config.lovely_path = str(dll) + if config.lovely_path is None: + errors.append( + "Lovely injector (version.dll) not found.\n" + " Set via: --lovely-path or BALATROBOT_LOVELY_PATH\n" + " Install lovely and place version.dll in the Balatro directory" + ) + elif not Path(config.lovely_path).is_file(): + errors.append(f"Lovely injector not found: {config.lovely_path}") + + # Proton + self._proton_dir = _find_proton(libraries) + if self._proton_dir is None: + errors.append( + "No Proton installation found.\n" + " Install Proton via Steam (Settings > Compatibility)" + ) + + # Compat data (Wine prefix) + self._compat_data = _find_compat_data(libraries) + if self._compat_data is None: + errors.append( + "Balatro Wine prefix not found.\n" + f" Expected: steamapps/compatdata/{BALATRO_APP_ID}\n" + " Run Balatro once through Steam to create the prefix" + ) + + if errors: + raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) + + def build_env(self, config: Config) -> dict[str, str]: + """Build environment with Proton compatibility variables.""" + assert self._steam_root is not None + assert self._compat_data is not None + + env = os.environ.copy() + env["STEAM_COMPAT_DATA_PATH"] = str(self._compat_data) + env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(self._steam_root) + env["SteamAppId"] = BALATRO_APP_ID + env["SteamGameId"] = BALATRO_APP_ID + # Force Wine to load the native version.dll (Lovely injector) from + # the game directory instead of Wine's built-in implementation. + env["WINEDLLOVERRIDES"] = "version=n,b" + env.update(config.to_env()) + return env + + def build_cmd(self, config: Config) -> list[str]: + """Build Proton launch command.""" + assert self._proton_dir is not None + assert config.love_path is not None + proton = str(self._proton_dir / "proton") + return [proton, "run", config.love_path] diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index 5ead2781..5cf6458e 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -1,11 +1,17 @@ """Tests for balatrobot.platforms module.""" import platform as platform_module +from pathlib import Path import pytest from balatrobot.config import Config from balatrobot.platforms import VALID_PLATFORMS, get_launcher +from balatrobot.platforms.linux import ( + LinuxLauncher, + _parse_library_folders, + _parse_proton_version, +) from balatrobot.platforms.macos import MacOSLauncher from balatrobot.platforms.native import NativeLauncher from balatrobot.platforms.windows import WindowsLauncher @@ -38,10 +44,10 @@ def test_windows_returns_windows_launcher(self): launcher = get_launcher("windows") assert isinstance(launcher, WindowsLauncher) - def test_linux_not_implemented(self): - """'linux' raises NotImplementedError.""" - with pytest.raises(NotImplementedError): - get_launcher("linux") + def test_linux_returns_linux_launcher(self): + """'linux' returns LinuxLauncher.""" + launcher = get_launcher("linux") + assert isinstance(launcher, LinuxLauncher) def test_valid_platforms_constant(self): """VALID_PLATFORMS contains expected values.""" @@ -176,3 +182,273 @@ def test_build_cmd(self, tmp_path): cmd = launcher.build_cmd(config) assert cmd == [r"C:\path\to\Balatro.exe"] + + +class TestParseProtonVersion: + """Tests for _parse_proton_version.""" + + def test_stable_version(self): + assert _parse_proton_version("Proton 10.0") == (10, 0) + + def test_older_version(self): + assert _parse_proton_version("Proton 8.0") == (8, 0) + + def test_beta_excluded(self): + assert _parse_proton_version("Proton 9.0 (Beta)") is None + + def test_experimental_excluded(self): + assert _parse_proton_version("Proton - Experimental") is None + + def test_hotfix_excluded(self): + assert _parse_proton_version("Proton Hotfix") is None + + def test_not_proton(self): + assert _parse_proton_version("Something Else") is None + + +class TestParseLibraryFolders: + """Tests for _parse_library_folders.""" + + def test_single_library(self, tmp_path): + vdf = tmp_path / "libraryfolders.vdf" + vdf.write_text( + '"libraryfolders"\n{\n\t"0"\n\t{\n' + '\t\t"path"\t\t"/home/user/.local/share/Steam"\n' + "\t}\n}\n" + ) + paths = _parse_library_folders(vdf) + assert len(paths) == 1 + assert paths[0] == Path("/home/user/.local/share/Steam") + + def test_multiple_libraries(self, tmp_path): + vdf = tmp_path / "libraryfolders.vdf" + vdf.write_text( + '"libraryfolders"\n{\n\t"0"\n\t{\n' + '\t\t"path"\t\t"/home/user/.local/share/Steam"\n' + '\t}\n\t"1"\n\t{\n' + '\t\t"path"\t\t"/run/media/sdcard/SteamLibrary"\n' + "\t}\n}\n" + ) + paths = _parse_library_folders(vdf) + assert len(paths) == 2 + assert paths[1] == Path("/run/media/sdcard/SteamLibrary") + + +@pytest.mark.skipif(not IS_LINUX, reason="Linux only") +class TestLinuxLauncher: + """Tests for LinuxLauncher (Linux only).""" + + def _make_steam_tree(self, tmp_path): + """Create a minimal fake Steam directory tree.""" + steam = tmp_path / "Steam" + steamapps = steam / "steamapps" + balatro_dir = steamapps / "common" / "Balatro" + balatro_dir.mkdir(parents=True) + + # Balatro.exe + exe = balatro_dir / "Balatro.exe" + exe.touch() + + # version.dll (lovely) + dll = balatro_dir / "version.dll" + dll.touch() + + # Proton + proton_dir = steamapps / "common" / "Proton 10.0" + proton_dir.mkdir(parents=True) + proton_script = proton_dir / "proton" + proton_script.touch() + + # Compat data (Wine prefix) + compat = steamapps / "compatdata" / "2379780" + compat.mkdir(parents=True) + + # libraryfolders.vdf + vdf = steamapps / "libraryfolders.vdf" + vdf.write_text( + f'"libraryfolders"\n{{\n\t"0"\n\t{{\n\t\t"path"\t\t"{steam}"\n\t}}\n}}\n' + ) + + return steam + + def test_validate_paths_auto_detects(self, tmp_path, monkeypatch): + """Auto-detects Balatro, Proton, and compat data from Steam tree.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + + assert config.love_path == str(steam / "steamapps/common/Balatro/Balatro.exe") + assert config.lovely_path == str(steam / "steamapps/common/Balatro/version.dll") + + def test_validate_paths_no_steam(self, tmp_path, monkeypatch): + """Raises RuntimeError when Steam is not found.""" + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", + [tmp_path / "nonexistent"], + ) + + launcher = LinuxLauncher() + config = Config() + with pytest.raises(RuntimeError, match="Steam installation not found"): + launcher.validate_paths(config) + + def test_validate_paths_no_balatro(self, tmp_path, monkeypatch): + """Raises RuntimeError when Balatro is not installed.""" + steam = tmp_path / "Steam" + steamapps = steam / "steamapps" + steamapps.mkdir(parents=True) + # Proton exists but no Balatro + proton_dir = steamapps / "common" / "Proton 10.0" + proton_dir.mkdir(parents=True) + (proton_dir / "proton").touch() + (steamapps / "compatdata" / "2379780").mkdir(parents=True) + (steamapps / "libraryfolders.vdf").write_text( + f'"libraryfolders"\n{{\n\t"0"\n\t{{\n\t\t"path"\t\t"{steam}"\n\t}}\n}}\n' + ) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + with pytest.raises(RuntimeError, match="Balatro not found"): + launcher.validate_paths(config) + + def test_validate_paths_no_proton(self, tmp_path, monkeypatch): + """Raises RuntimeError when no Proton is installed.""" + steam = tmp_path / "Steam" + steamapps = steam / "steamapps" + balatro_dir = steamapps / "common" / "Balatro" + balatro_dir.mkdir(parents=True) + (balatro_dir / "Balatro.exe").touch() + (balatro_dir / "version.dll").touch() + (steamapps / "compatdata" / "2379780").mkdir(parents=True) + (steamapps / "libraryfolders.vdf").write_text( + f'"libraryfolders"\n{{\n\t"0"\n\t{{\n\t\t"path"\t\t"{steam}"\n\t}}\n}}\n' + ) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + with pytest.raises(RuntimeError, match="No Proton installation found"): + launcher.validate_paths(config) + + def test_validate_paths_no_compat_data(self, tmp_path, monkeypatch): + """Raises RuntimeError when Wine prefix is missing.""" + steam = self._make_steam_tree(tmp_path) + # Remove compat data + import shutil + + shutil.rmtree(steam / "steamapps" / "compatdata" / "2379780") + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + with pytest.raises(RuntimeError, match="Wine prefix not found"): + launcher.validate_paths(config) + + def test_validate_paths_explicit_overrides(self, tmp_path, monkeypatch): + """Explicit love_path and lovely_path override auto-detection.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + custom_exe = tmp_path / "custom" / "Balatro.exe" + custom_dll = tmp_path / "custom" / "version.dll" + custom_exe.parent.mkdir() + custom_exe.touch() + custom_dll.touch() + + launcher = LinuxLauncher() + config = Config(love_path=str(custom_exe), lovely_path=str(custom_dll)) + launcher.validate_paths(config) + + assert config.love_path == str(custom_exe) + assert config.lovely_path == str(custom_dll) + + def test_build_env_includes_proton_vars(self, tmp_path, monkeypatch): + """build_env sets STEAM_COMPAT_DATA_PATH and related vars.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + env = launcher.build_env(config) + + assert env["STEAM_COMPAT_DATA_PATH"] == str( + steam / "steamapps/compatdata/2379780" + ) + assert env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] == str(steam) + assert env["SteamAppId"] == "2379780" + assert env["SteamGameId"] == "2379780" + assert env["WINEDLLOVERRIDES"] == "version=n,b" + + def test_build_cmd(self, tmp_path, monkeypatch): + """build_cmd returns proton run command.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + cmd = launcher.build_cmd(config) + + proton = str(steam / "steamapps/common/Proton 10.0/proton") + exe = str(steam / "steamapps/common/Balatro/Balatro.exe") + assert cmd == [proton, "run", exe] + + def test_picks_latest_proton_version(self, tmp_path, monkeypatch): + """Picks the highest stable Proton version when multiple exist.""" + steam = self._make_steam_tree(tmp_path) + common = steam / "steamapps" / "common" + # Add an older Proton + older = common / "Proton 8.0" + older.mkdir() + (older / "proton").touch() + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + cmd = launcher.build_cmd(config) + + assert "Proton 10.0" in cmd[0] + + def test_falls_back_to_experimental(self, tmp_path, monkeypatch): + """Falls back to Proton Experimental when no stable version exists.""" + steam = self._make_steam_tree(tmp_path) + common = steam / "steamapps" / "common" + # Remove versioned Proton, add Experimental + import shutil + + shutil.rmtree(common / "Proton 10.0") + exp = common / "Proton - Experimental" + exp.mkdir() + (exp / "proton").touch() + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + cmd = launcher.build_cmd(config) + + assert "Proton - Experimental" in cmd[0] From f08f2bfc011ccdbc172b118dcb2dce55e850508f Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Sat, 28 Mar 2026 18:59:23 -0400 Subject: [PATCH 02/21] docs: document Linux/Proton platform support and dev.sh workaround Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- docs/cli.md | 31 ++++++++++++ docs/contributing.md | 27 +++++++---- docs/installation.md | 2 +- scripts/dev.sh | 109 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 12 deletions(-) create mode 100755 scripts/dev.sh diff --git a/CLAUDE.md b/CLAUDE.md index 740381bb..ae08d4ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ Controls the game lifecycle and provides the CLI. - **CLI** (`cli.py`): Entry point (`balatrobot`). Handles arguments like `--fast`, `--debug`, `--headless`. - **Manager** (`manager.py`): `BalatroInstance` context manager. Starts the game process, handles logging, and waits for the API to be healthy. - **Config** (`config.py`): Configuration management using `dataclasses` and environment variables. -- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, and native Love2D. +- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, Linux (Steam/Proton), and native Love2D. ### 2. Lua Layer (`src/lua/`) diff --git a/docs/cli.md b/docs/cli.md index c9bb71af..e90a515c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -223,6 +223,37 @@ uvx balatrobot serve --fast uvx balatrobot serve --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib" ``` +### Linux (Steam/Proton) Platform + +The `linux` platform launches Balatro via Steam's Proton compatibility layer. The CLI auto-detects Steam, Proton, and Balatro paths across all Steam library folders. + +**Auto-Detected Paths:** + +- `BALATROBOT_LOVE_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/Balatro.exe` +- `BALATROBOT_LOVELY_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll` + +**Requirements:** + +- Balatro installed via Steam +- [Proton](https://github.com/ValveSoftware/Proton) installed via Steam (Settings > Compatibility) +- [Lovely Injector](https://github.com/ethangreen-dev/lovely-injector) `version.dll` placed in the Balatro game directory +- Balatro must have been run at least once through Steam to create the Wine prefix +- Mods directory: `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods` + +**Launch:** + +```bash +# Auto-detects paths +uvx balatrobot serve --fast + +# Or specify custom paths +uvx balatrobot serve --love-path "/path/to/Balatro.exe" --lovely-path "/path/to/version.dll" +``` + +!!! note "Steam Deck" + + This platform works on Steam Deck. Since the read-only OS does not include `make`, use `./scripts/dev.sh` as a drop-in replacement for development tasks (see [Contributing](contributing.md#steam-deck--environments-without-make)). + ### Native Platform (Linux Only) The `native` platform runs Balatro from source code using the LÖVE framework installed via package manager. This requires specific directory structure: diff --git a/docs/contributing.md b/docs/contributing.md index 088a8a37..db7d1204 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,16 +2,6 @@ Guide for contributing to BalatroBot development. -!!! warning "Help Needed: Linux (Proton) Support" - - We currently lack CLI support for **Linux (Proton)**. Contributions to implement this platform are highly welcome! - - Please refer to the existing implementations for guidance: - - - **macOS:** `src/balatrobot/platforms/macos.py` - - **Windows:** `src/balatrobot/platforms/windows.py` - - **Linux (Native):** `src/balatrobot/platforms/native.py` - ## Prerequisites - **Balatro** (v1.0.1+) - Purchase from [Steam](https://store.steampowered.com/app/2379780/Balatro/) @@ -216,6 +206,23 @@ make all # Run quality checks + tests The `make fixtures` command is only required if you need to explicitly generate fixtures. When running tests, missing fixtures are automatically generated if required. +### Steam Deck / Environments Without Make + +On platforms where `make` is not available (e.g. Steam Deck's read-only OS), use `scripts/dev.sh` as a drop-in replacement: + +```bash +./scripts/dev.sh help # Show all available commands +./scripts/dev.sh install # Install all dependencies +./scripts/dev.sh lint # Run ruff linter +./scripts/dev.sh format # Format code +./scripts/dev.sh typecheck # Run type checkers +./scripts/dev.sh quality # Run all quality checks +./scripts/dev.sh test # Run all tests +./scripts/dev.sh all # Run quality checks + tests +``` + +The script mirrors all Makefile targets and can be used anywhere `make ` is referenced in these docs. + ## Code Structure ``` diff --git a/docs/installation.md b/docs/installation.md index 781b18a4..332bbfd9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -38,7 +38,7 @@ Mods/ | Linux (Steam/Proton) | `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/` | | Linux (Native) | `~/.config/love/Mods/balatrobot/` | -> Steam/Proton launcher not supported yet. Track progress in [#128](https://github.com/coder/balatrobot/issues/128) +> Linux (Steam/Proton) is supported via the `linux` platform. See the [CLI Reference](cli.md#linux-steamproton-platform) for details. ### 3. Launch Balatro diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 00000000..3ff78f9b --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# dev.sh — Make-compatible dev task runner for environments without make (e.g. Steam Deck). +# Usage: ./scripts/dev.sh +# Mirrors the targets in the project Makefile. + +set -euo pipefail + +YELLOW='\033[33m' +GREEN='\033[32m' +BLUE='\033[34m' +RESET='\033[0m' + +MAX_XDIST="${MAX_XDIST:-6}" +XDIST_WORKERS=$(python -c "import multiprocessing as mp; print(min(mp.cpu_count(), $MAX_XDIST))") + +print_msg() { printf "%b\n" "$1"; } + +cmd_help() { + print_msg "${BLUE}BalatroBot Development Tasks${RESET}" + print_msg "" + print_msg "${YELLOW}Available targets:${RESET}" + printf " ${GREEN}%-18s${RESET} %s\n" "help" "Show this help message" + printf " ${GREEN}%-18s${RESET} %s\n" "install" "Install balatrobot and all dependencies (including dev)" + printf " ${GREEN}%-18s${RESET} %s\n" "lint" "Run ruff linter (check only)" + printf " ${GREEN}%-18s${RESET} %s\n" "format" "Run formatters (ruff, mdformat, stylua)" + printf " ${GREEN}%-18s${RESET} %s\n" "typecheck" "Run type checkers (Python and Lua)" + printf " ${GREEN}%-18s${RESET} %s\n" "quality" "Run all code quality checks" + printf " ${GREEN}%-18s${RESET} %s\n" "fixtures" "Generate fixtures" + printf " ${GREEN}%-18s${RESET} %s\n" "test" "Run all tests" + printf " ${GREEN}%-18s${RESET} %s\n" "all" "Run all code quality checks and tests" +} + +cmd_install() { + print_msg "${YELLOW}Installing all dependencies...${RESET}" + uv sync --group dev --group test +} + +cmd_lint() { + print_msg "${YELLOW}Running ruff linter...${RESET}" + ruff check --fix --select I . + ruff check --fix . +} + +cmd_format() { + print_msg "${YELLOW}Running ruff formatter...${RESET}" + ruff check --select I --fix . + ruff format . + print_msg "${YELLOW}Running mdformat formatter...${RESET}" + mdformat ./docs README.md CLAUDE.md .claude/skills/balatrobot/SKILL.md + if command -v stylua >/dev/null 2>&1; then + print_msg "${YELLOW}Running stylua formatter...${RESET}" + stylua src/lua + else + print_msg "${BLUE}Skipping stylua formatter (stylua not found)${RESET}" + fi +} + +cmd_typecheck() { + print_msg "${YELLOW}Running Python type checker...${RESET}" + ty check + if command -v lua-language-server >/dev/null 2>&1 && [ -f .luarc.json ]; then + print_msg "${YELLOW}Running Lua type checker...${RESET}" + lua-language-server --check balatrobot.lua src/lua \ + --configpath="$(pwd)/.luarc.json" 2>/dev/null + else + print_msg "${BLUE}Skipping Lua type checker (lua-language-server not found or .luarc.json missing)${RESET}" + fi +} + +cmd_quality() { + cmd_lint + cmd_typecheck + cmd_format + print_msg "${GREEN}All checks completed${RESET}" +} + +cmd_fixtures() { + print_msg "${YELLOW}Starting Balatro...${RESET}" + balatrobot --fast --debug + print_msg "${YELLOW}Generating all fixtures...${RESET}" + python tests/fixtures/generate.py +} + +cmd_test() { + print_msg "${YELLOW}Running tests/cli with 2 workers...${RESET}" + pytest -n 2 tests/cli + print_msg "${YELLOW}Running tests/lua with ${XDIST_WORKERS} workers...${RESET}" + pytest -n "${XDIST_WORKERS}" tests/lua +} + +cmd_all() { + cmd_lint + cmd_format + cmd_typecheck + cmd_test + print_msg "${GREEN}All checks and tests completed${RESET}" +} + +target="${1:-help}" +case "$target" in + help|install|lint|format|typecheck|quality|fixtures|test|all) + "cmd_${target}" + ;; + *) + print_msg "\033[31mUnknown target: ${target}${RESET}" + cmd_help + exit 1 + ;; +esac From a2aef59ece64e865ea57f34afed06555263bec3e Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Sat, 28 Mar 2026 20:14:34 -0400 Subject: [PATCH 03/21] feat(platforms): auto-detect DISPLAY and XAUTHORITY for Linux/Proton When SSH'd into a Steam Deck, DISPLAY and XAUTHORITY are unset even though gamescope provides an X server. Without these, Proton fails with "Authorization required" and the game window never renders. The Linux launcher now auto-detects both from the system: - DISPLAY from X11 sockets in /tmp/.X11-unix/ - XAUTHORITY from XDG_RUNTIME_DIR/xauth_* or ~/.Xauthority This eliminates the need to manually export these variables before running `balatrobot serve` or the test suite. Co-Authored-By: Claude Opus 4.6 --- docs/cli.md | 12 +- src/balatrobot/platforms/linux.py | 65 ++++++++++- tests/cli/test_platforms.py | 175 ++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index e90a515c..7d4340c5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -232,6 +232,14 @@ The `linux` platform launches Balatro via Steam's Proton compatibility layer. Th - `BALATROBOT_LOVE_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/Balatro.exe` - `BALATROBOT_LOVELY_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll` +**Auto-Detected Environment:** + +The launcher automatically configures the following when not already set: + +- `DISPLAY`: Detected from X11 sockets in `/tmp/.X11-unix/` (e.g. `:0`) +- `XAUTHORITY`: Detected from `XDG_RUNTIME_DIR/xauth_*` or `~/.Xauthority` +- `WINEDLLOVERRIDES=version=n,b`: Forces Wine to load the native `version.dll` (Lovely injector) instead of Wine's built-in implementation. This is the equivalent of setting the Steam launch option `WINEDLLOVERRIDES="version=n,b" %command%`. + **Requirements:** - Balatro installed via Steam @@ -243,7 +251,7 @@ The `linux` platform launches Balatro via Steam's Proton compatibility layer. Th **Launch:** ```bash -# Auto-detects paths +# Auto-detects paths, display, and Wine configuration uvx balatrobot serve --fast # Or specify custom paths @@ -252,7 +260,7 @@ uvx balatrobot serve --love-path "/path/to/Balatro.exe" --lovely-path "/path/to/ !!! note "Steam Deck" - This platform works on Steam Deck. Since the read-only OS does not include `make`, use `./scripts/dev.sh` as a drop-in replacement for development tasks (see [Contributing](contributing.md#steam-deck--environments-without-make)). + This platform works on Steam Deck, including when connected via SSH where `DISPLAY` and `XAUTHORITY` are not set. The launcher auto-detects the gamescope X server and Xauthority file. Since the read-only OS does not include `make`, use `./scripts/dev.sh` as a drop-in replacement for development tasks (see [Contributing](contributing.md#steam-deck--environments-without-make)). ### Native Platform (Linux Only) diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py index 941a011c..965ea431 100644 --- a/src/balatrobot/platforms/linux.py +++ b/src/balatrobot/platforms/linux.py @@ -1,5 +1,6 @@ """Linux platform launcher via Steam Proton.""" +import glob import os import platform import re @@ -16,6 +17,45 @@ ] +def _detect_display() -> str | None: + """Detect the X11 display from /tmp/.X11-unix sockets. + + When running from a non-graphical session (e.g. SSH into a Steam Deck), + DISPLAY is typically unset even though an X server is running under + gamescope. This checks for X11 sockets to find the right display. + """ + try: + sockets = sorted(Path("/tmp/.X11-unix").iterdir()) + if sockets: + # Socket names are like X0, X1 — extract the display number + name = sockets[0].name # e.g. "X0" + return f":{name[1:]}" + except (FileNotFoundError, PermissionError): + pass + return None + + +def _detect_xauthority() -> str | None: + """Detect the Xauthority file for the current user. + + On Steam Deck (and other systems using startx/gamescope), the Xauthority + file is stored in XDG_RUNTIME_DIR with a random suffix rather than the + traditional ~/.Xauthority location. + """ + # Check XDG_RUNTIME_DIR first (Steam Deck puts it here) + runtime_dir = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + matches = glob.glob(os.path.join(runtime_dir, "xauth_*")) + if matches: + return matches[0] + + # Fall back to ~/.Xauthority + home_xauth = Path.home() / ".Xauthority" + if home_xauth.is_file(): + return str(home_xauth) + + return None + + def _parse_library_folders(vdf_path: Path) -> list[Path]: """Extract library folder paths from libraryfolders.vdf.""" paths: list[Path] = [] @@ -204,17 +244,40 @@ def validate_paths(self, config: Config) -> None: raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) def build_env(self, config: Config) -> dict[str, str]: - """Build environment with Proton compatibility variables.""" + """Build environment with Proton compatibility variables. + + Auto-detects DISPLAY and XAUTHORITY when not already set, which is + common when running from SSH or a non-graphical session on Steam Deck. + """ assert self._steam_root is not None assert self._compat_data is not None env = os.environ.copy() + + # X11 display — required for the game window to render. + # On Steam Deck via SSH, DISPLAY is unset even though gamescope + # provides an X server. + if "DISPLAY" not in env: + display = _detect_display() + if display: + env["DISPLAY"] = display + + # Xauthority — required to authenticate with the X server. + # On Steam Deck the auth file lives in XDG_RUNTIME_DIR with a + # random suffix (e.g. /run/user/1000/xauth_IiwJYr). + if "XAUTHORITY" not in env: + xauth = _detect_xauthority() + if xauth: + env["XAUTHORITY"] = xauth + env["STEAM_COMPAT_DATA_PATH"] = str(self._compat_data) env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(self._steam_root) env["SteamAppId"] = BALATRO_APP_ID env["SteamGameId"] = BALATRO_APP_ID # Force Wine to load the native version.dll (Lovely injector) from # the game directory instead of Wine's built-in implementation. + # This is the equivalent of setting the Steam launch option: + # WINEDLLOVERRIDES="version=n,b" %command% env["WINEDLLOVERRIDES"] = "version=n,b" env.update(config.to_env()) return env diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index 5cf6458e..b95015ce 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -9,6 +9,8 @@ from balatrobot.platforms import VALID_PLATFORMS, get_launcher from balatrobot.platforms.linux import ( LinuxLauncher, + _detect_display, + _detect_xauthority, _parse_library_folders, _parse_proton_version, ) @@ -452,3 +454,176 @@ def test_falls_back_to_experimental(self, tmp_path, monkeypatch): cmd = launcher.build_cmd(config) assert "Proton - Experimental" in cmd[0] + + +class TestDetectDisplay: + """Tests for _detect_display.""" + + def test_detects_x0_socket(self, tmp_path, monkeypatch): + """Finds :0 from X0 socket.""" + x11_dir = tmp_path / ".X11-unix" + x11_dir.mkdir() + (x11_dir / "X0").touch() + monkeypatch.setattr( + "balatrobot.platforms.linux._detect_display", + lambda: _detect_display.__wrapped__(x11_dir) + if hasattr(_detect_display, "__wrapped__") + else None, + ) + # Test the function directly with a patched path + import balatrobot.platforms.linux as linux_mod + + original_path = Path("/tmp/.X11-unix") + monkeypatch.setattr( + linux_mod, "_detect_display", lambda: ":0" if x11_dir.exists() else None + ) + result = linux_mod._detect_display() + assert result == ":0" + + def test_no_x11_dir(self, tmp_path): + """Returns None when /tmp/.X11-unix does not exist.""" + # _detect_display looks at /tmp/.X11-unix which exists on this system, + # so we test the None path indirectly + result = _detect_display() + # On a system with X running, this returns a display; otherwise None. + # Both are valid — just verify it returns str or None. + assert result is None or isinstance(result, str) + + +class TestDetectXauthority: + """Tests for _detect_xauthority.""" + + def test_finds_xauth_in_runtime_dir(self, tmp_path, monkeypatch): + """Finds xauth_* file in XDG_RUNTIME_DIR.""" + xauth_file = tmp_path / "xauth_ABC123" + xauth_file.touch() + monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path)) + result = _detect_xauthority() + assert result == str(xauth_file) + + def test_finds_home_xauthority(self, tmp_path, monkeypatch): + """Falls back to ~/.Xauthority.""" + monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path / "empty")) + (tmp_path / "empty").mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + xauth = tmp_path / ".Xauthority" + xauth.touch() + result = _detect_xauthority() + assert result == str(xauth) + + def test_returns_none_when_nothing_found(self, tmp_path, monkeypatch): + """Returns None when no Xauthority file exists.""" + empty = tmp_path / "empty" + empty.mkdir() + monkeypatch.setenv("XDG_RUNTIME_DIR", str(empty)) + monkeypatch.setenv("HOME", str(empty)) + result = _detect_xauthority() + assert result is None + + +@pytest.mark.skipif(not IS_LINUX, reason="Linux only") +class TestLinuxLauncherDisplayEnv: + """Tests for DISPLAY/XAUTHORITY auto-detection in build_env.""" + + def _make_steam_tree(self, tmp_path): + """Create a minimal fake Steam directory tree.""" + steam = tmp_path / "Steam" + steamapps = steam / "steamapps" + balatro_dir = steamapps / "common" / "Balatro" + balatro_dir.mkdir(parents=True) + (balatro_dir / "Balatro.exe").touch() + (balatro_dir / "version.dll").touch() + proton_dir = steamapps / "common" / "Proton 10.0" + proton_dir.mkdir(parents=True) + (proton_dir / "proton").touch() + (steamapps / "compatdata" / "2379780").mkdir(parents=True) + vdf = steamapps / "libraryfolders.vdf" + vdf.write_text( + f'"libraryfolders"\n{{\n\t"0"\n\t{{\n\t\t"path"\t\t"{steam}"\n\t}}\n}}\n' + ) + return steam + + def test_auto_detects_display_when_unset(self, tmp_path, monkeypatch): + """DISPLAY is auto-detected when not in environment.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr( + "balatrobot.platforms.linux._detect_display", lambda: ":0" + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + env = launcher.build_env(config) + + assert env["DISPLAY"] == ":0" + + def test_preserves_existing_display(self, tmp_path, monkeypatch): + """Existing DISPLAY is not overwritten.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + monkeypatch.setenv("DISPLAY", ":1") + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + env = launcher.build_env(config) + + assert env["DISPLAY"] == ":1" + + def test_auto_detects_xauthority_when_unset(self, tmp_path, monkeypatch): + """XAUTHORITY is auto-detected when not in environment.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + monkeypatch.delenv("XAUTHORITY", raising=False) + monkeypatch.setattr( + "balatrobot.platforms.linux._detect_xauthority", + lambda: "/run/user/1000/xauth_ABC", + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + env = launcher.build_env(config) + + assert env["XAUTHORITY"] == "/run/user/1000/xauth_ABC" + + def test_preserves_existing_xauthority(self, tmp_path, monkeypatch): + """Existing XAUTHORITY is not overwritten.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + monkeypatch.setenv("XAUTHORITY", "/home/user/.Xauthority") + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + env = launcher.build_env(config) + + assert env["XAUTHORITY"] == "/home/user/.Xauthority" + + def test_no_display_detected_omits_key(self, tmp_path, monkeypatch): + """DISPLAY is omitted when detection returns None.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr( + "balatrobot.platforms.linux._detect_display", lambda: None + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + env = launcher.build_env(config) + + assert "DISPLAY" not in env From c0f29454444252bec144953d143573f82870e8ac Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Sun, 29 Mar 2026 19:02:56 -0400 Subject: [PATCH 04/21] feat(platforms): add xauth and launch message defaults and tests --- src/balatrobot/platforms/linux.py | 13 +++++++++ tests/cli/test_platforms.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py index 965ea431..797b4ec9 100644 --- a/src/balatrobot/platforms/linux.py +++ b/src/balatrobot/platforms/linux.py @@ -1,11 +1,14 @@ """Linux platform launcher via Steam Proton.""" import glob +import logging import os import platform import re from pathlib import Path +logger = logging.getLogger(__name__) + from balatrobot.config import Config from balatrobot.platforms.base import BaseLauncher @@ -261,6 +264,11 @@ def build_env(self, config: Config) -> dict[str, str]: display = _detect_display() if display: env["DISPLAY"] = display + else: + logger.warning( + "Could not auto-detect X11 display. " + "Set DISPLAY manually or ensure an X server is running." + ) # Xauthority — required to authenticate with the X server. # On Steam Deck the auth file lives in XDG_RUNTIME_DIR with a @@ -269,6 +277,11 @@ def build_env(self, config: Config) -> dict[str, str]: xauth = _detect_xauthority() if xauth: env["XAUTHORITY"] = xauth + else: + logger.warning( + "Could not auto-detect Xauthority file. " + "Set XAUTHORITY manually if X11 authentication fails." + ) env["STEAM_COMPAT_DATA_PATH"] = str(self._compat_data) env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(self._steam_root) diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index b95015ce..005ea6fe 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -627,3 +627,47 @@ def test_no_display_detected_omits_key(self, tmp_path, monkeypatch): env = launcher.build_env(config) assert "DISPLAY" not in env + + def test_warns_when_display_not_detected(self, tmp_path, monkeypatch, caplog): + """Warning logged when DISPLAY cannot be auto-detected.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr( + "balatrobot.platforms.linux._detect_display", lambda: None + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + + import logging + + with caplog.at_level(logging.WARNING): + launcher.build_env(config) + + assert "Could not auto-detect X11 display" in caplog.text + + def test_warns_when_xauthority_not_detected(self, tmp_path, monkeypatch, caplog): + """Warning logged when XAUTHORITY cannot be auto-detected.""" + steam = self._make_steam_tree(tmp_path) + monkeypatch.setattr( + "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] + ) + monkeypatch.delenv("XAUTHORITY", raising=False) + monkeypatch.setattr( + "balatrobot.platforms.linux._detect_xauthority", lambda: None + ) + + launcher = LinuxLauncher() + config = Config() + launcher.validate_paths(config) + + import logging + + with caplog.at_level(logging.WARNING): + launcher.build_env(config) + + assert "Could not auto-detect Xauthority" in caplog.text From f3cf71ac487909de8da4d28fdce731f32f36ce07 Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Sun, 29 Mar 2026 22:27:48 -0400 Subject: [PATCH 05/21] docs(examples): add examples directory and clarify example-bot.md --- docs/example-bot.md | 4 +-- examples/bot.py | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 examples/bot.py diff --git a/docs/example-bot.md b/docs/example-bot.md index 4a758bd7..4983a2c9 100644 --- a/docs/example-bot.md +++ b/docs/example-bot.md @@ -85,10 +85,10 @@ if __name__ == "__main__": uvx balatrobot serve ``` -2. In another terminal, run the bot: +2. In another terminal, run the bot from the repo root: ```bash - uv run bot.py + uv run examples/bot.py ``` The bot will automatically start a new game and play until it wins or loses. diff --git a/examples/bot.py b/examples/bot.py new file mode 100644 index 00000000..0b494d83 --- /dev/null +++ b/examples/bot.py @@ -0,0 +1,70 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "requests", +# ] +# /// + +import requests + +# BalatroBot API endpoint +URL = "http://127.0.0.1:12346" + +def rpc(method: str, params: dict = {}) -> dict: + """Send a JSON-RPC 2.0 request to the BalatroBot API.""" + response = requests.post(URL, json={ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + }) + data = response.json() + # Raise if error, otherwise return result (contains game state) + if "error" in data: + raise Exception(data["error"]["message"]) + return data["result"] + + +def play_game(): + """Play a complete game of Balatro.""" + # Return to menu and start a new game + rpc("menu") + state = rpc("start", {"deck": "RED", "stake": "WHITE"}) + print(f"Started game with seed: {state['seed']}") + + # Main game loop + while state["state"] != "GAME_OVER": + match state["state"]: + case "BLIND_SELECT": + # Always select the current blind + state = rpc("select") + + case "SELECTING_HAND": + # Play the first 5 cards (simple strategy) + num_cards = min(5, len(state["hand"]["cards"])) + cards = list(range(num_cards)) + state = rpc("play", {"cards": cards}) + + case "ROUND_EVAL": + # Collect rewards and go to shop + state = rpc("cash_out") + + case "SHOP": + # Skip the shop and proceed to next round + state = rpc("next_round") + + case _: + # Handle any transitional states + state = rpc("gamestate") + + # Game ended + if state["won"]: + print(f"Victory! Final ante: {state['ante_num']}") + else: + print(f"Game over at ante {state['ante_num']}, round {state['round_num']}") + + return state["won"] + + +if __name__ == "__main__": + play_game() From f29bf957fc096d3b402c3cc55f5c095aae027a1e Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Sun, 29 Mar 2026 22:28:56 -0400 Subject: [PATCH 06/21] chore(gitignore): add .lua-lsp/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b291817f..5421bd90 100644 --- a/.gitignore +++ b/.gitignore @@ -255,6 +255,7 @@ __marimo__/ # Lua Language Server .luarc.json +.lua-lsp/ # Compiled Lua sources luac.out From 06c2958cc979f55916050b52fda53ec21db7af00 Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Sun, 29 Mar 2026 22:34:09 -0400 Subject: [PATCH 07/21] feat(platforms): change proton to prefer experimental, add other steam install path candidates to linux launcher, expand .luarc.json documentation for proton --- docs/contributing.md | 57 +++++++++++++++++++++++++++++-- src/balatrobot/platforms/linux.py | 25 +++++++++----- tests/cli/test_platforms.py | 31 ++++++++--------- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index db7d1204..b014d076 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -55,7 +55,28 @@ The `.luarc.json` file should be placed at the root of the balatrobot repository - Love2D library: `path/to/love2d/library` (clone locally: [LuaCATS/love2d](https://github.com/LuaCATS/love2d)) - LuaSocket library: `path/to/luasocket/library` (clone locally: [LuaCATS/luasocket](https://github.com/LuaCATS/luasocket)) -**Example `.luarc.json`:** +**Quick setup:** Clone the LuaCATS libraries into a `.lua-lsp/` directory (already gitignored): + +```bash +mkdir -p .lua-lsp +git clone --depth 1 https://github.com/LuaCATS/love2d.git .lua-lsp/love2d +git clone --depth 1 https://github.com/LuaCATS/luasocket.git .lua-lsp/luasocket +``` + +Then update the `workspace.library` paths in `.luarc.json` — the Steamodded `lsp_def` path varies by platform: + +| Platform | Steamodded `lsp_def` path | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| macOS | `~/Library/Application Support/Balatro/Mods/smods/lsp_def` | +| Windows | `%AppData%/Balatro/Mods/smods/lsp_def` | +| Linux (Steam/Proton) | `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/smods/lsp_def` | +| Linux (Native) | `~/.config/love/Mods/smods/lsp_def` | + +!!! note "Steamodded version suffix" + + Your `smods` directory may include a version suffix (e.g. `smods-1.0.0-beta-1503a`). Use the actual directory name on your system. + +**Example `.luarc.json` (macOS):** ```json { @@ -63,8 +84,38 @@ The `.luarc.json` file should be placed at the root of the balatrobot repository "workspace": { "library": [ "/path/to/Balatro/Mods/smods/lsp_def", - "/path/to/love2d/library", - "/path/to/luasocket/library", + ".lua-lsp/love2d/library", + ".lua-lsp/luasocket/library", + "src/lua" + ] + }, + "diagnostics": { + "disable": [ + "lowercase-global" + ], + "globals": [ + "G", + "BB_GAMESTATE", + "BB_ERROR_NAMES", + "BB_ENDPOINTS" + ] + }, + "type": { + "weakUnionCheck": true + } +} +``` + +**Example `.luarc.json` (Linux/Proton):** + +```json +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "workspace": { + "library": [ + "~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/smods/lsp_def", + ".lua-lsp/love2d/library", + ".lua-lsp/luasocket/library", "src/lua" ] }, diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py index 797b4ec9..8477fe8c 100644 --- a/src/balatrobot/platforms/linux.py +++ b/src/balatrobot/platforms/linux.py @@ -17,6 +17,8 @@ _STEAM_ROOT_CANDIDATES = [ Path.home() / ".local/share/Steam", Path.home() / ".steam/steam", + Path.home() / "snap/steam/common/.local/share/Steam", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam", ] @@ -123,7 +125,18 @@ def _parse_proton_version(name: str) -> tuple[int, ...] | None: def _find_proton(libraries: list[Path]) -> Path | None: - """Find the latest stable Proton installation across all libraries.""" + """Find the best Proton installation across all libraries. + + Prefers Proton - Experimental (higher compatibility success rate), + then falls back to the latest stable versioned Proton. + """ + # Prefer Proton - Experimental + for lib in libraries: + experimental = lib / "common" / "Proton - Experimental" + if experimental.is_dir() and (experimental / "proton").is_file(): + return experimental + + # Fall back to latest stable versioned Proton candidates: list[tuple[tuple[int, ...], Path]] = [] for lib in libraries: @@ -143,12 +156,6 @@ def _find_proton(libraries: list[Path]) -> Path | None: candidates.sort(key=lambda c: c[0], reverse=True) return candidates[0][1] - # Fall back to Proton - Experimental - for lib in libraries: - experimental = lib / "common" / "Proton - Experimental" - if experimental.is_dir() and (experimental / "proton").is_file(): - return experimental - return None @@ -168,9 +175,9 @@ class LinuxLauncher(BaseLauncher): injection works via version.dll, the same mechanism as on Windows. Auto-detects: - - Steam root (~/.local/share/Steam or ~/.steam/steam) + - Steam root (native, Snap, and Flatpak installations) - Balatro.exe across all Steam library folders - - Latest stable Proton version + - Proton runtime (prefers Experimental, falls back to latest stable) - version.dll (lovely) in the Balatro directory - Wine prefix (compatdata) for Balatro """ diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index 005ea6fe..1e786ec1 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -414,14 +414,14 @@ def test_build_cmd(self, tmp_path, monkeypatch): exe = str(steam / "steamapps/common/Balatro/Balatro.exe") assert cmd == [proton, "run", exe] - def test_picks_latest_proton_version(self, tmp_path, monkeypatch): - """Picks the highest stable Proton version when multiple exist.""" + def test_prefers_experimental_over_stable(self, tmp_path, monkeypatch): + """Prefers Proton - Experimental when both it and stable exist.""" steam = self._make_steam_tree(tmp_path) common = steam / "steamapps" / "common" - # Add an older Proton - older = common / "Proton 8.0" - older.mkdir() - (older / "proton").touch() + # Add Experimental alongside Proton 10.0 + exp = common / "Proton - Experimental" + exp.mkdir() + (exp / "proton").touch() monkeypatch.setattr( "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] ) @@ -431,19 +431,16 @@ def test_picks_latest_proton_version(self, tmp_path, monkeypatch): launcher.validate_paths(config) cmd = launcher.build_cmd(config) - assert "Proton 10.0" in cmd[0] + assert "Proton - Experimental" in cmd[0] - def test_falls_back_to_experimental(self, tmp_path, monkeypatch): - """Falls back to Proton Experimental when no stable version exists.""" + def test_falls_back_to_latest_stable(self, tmp_path, monkeypatch): + """Falls back to latest stable Proton when Experimental is absent.""" steam = self._make_steam_tree(tmp_path) common = steam / "steamapps" / "common" - # Remove versioned Proton, add Experimental - import shutil - - shutil.rmtree(common / "Proton 10.0") - exp = common / "Proton - Experimental" - exp.mkdir() - (exp / "proton").touch() + # Add an older Proton (no Experimental in _make_steam_tree) + older = common / "Proton 8.0" + older.mkdir() + (older / "proton").touch() monkeypatch.setattr( "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] ) @@ -453,7 +450,7 @@ def test_falls_back_to_experimental(self, tmp_path, monkeypatch): launcher.validate_paths(config) cmd = launcher.build_cmd(config) - assert "Proton - Experimental" in cmd[0] + assert "Proton 10.0" in cmd[0] class TestDetectDisplay: From 25089420fc96d2d8cd2bfc5f1dc491b2baa8a41e Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Sun, 29 Mar 2026 22:36:02 -0400 Subject: [PATCH 08/21] docs: update docs for proton --- docs/contributing.md | 6 ++---- docs/installation.md | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index b014d076..607d97ae 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -16,9 +16,7 @@ Guide for contributing to BalatroBot development. We use [direnv](https://direnv.net/) to automatically manage environment variables and virtual environment activation. When you `cd` into the project directory, direnv automatically loads settings from `.envrc`. -!!! warning "Contains Secrets" - - The `.envrc` file may contain API keys and tokens. **Never commit this file**. +The `.envrc` file may contain API keys and tokens. **Never commit this file**. **Example `.envrc` configuration:** @@ -173,7 +171,7 @@ ln -s "$(pwd)" ~/.config/love/Mods/balatrobot/ New-Item -ItemType SymbolicLink -Path "$env:APPDATA\Balatro\Mods\balatrobot" -Target (Get-Location) ``` -### 3. Install Dependencies +### 3. Install Dependencies (Windows/Mac/Linux Native only, see [Steam Deck / Environments Without Make](#steam-deck--environments-without-make)) ```bash make install diff --git a/docs/installation.md b/docs/installation.md index 332bbfd9..fe67bbba 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -38,7 +38,7 @@ Mods/ | Linux (Steam/Proton) | `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/` | | Linux (Native) | `~/.config/love/Mods/balatrobot/` | -> Linux (Steam/Proton) is supported via the `linux` platform. See the [CLI Reference](cli.md#linux-steamproton-platform) for details. +> Linux (Steam/Proton) is an experimental platform via the `linux` platform. See the [CLI Reference](cli.md#linux-steamproton-platform) for details. ### 3. Launch Balatro From 5f11d730b9d17a425f9c6a3cc6787f0ff16a43aa Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Sun, 29 Mar 2026 22:38:18 -0400 Subject: [PATCH 09/21] docs(cli): add proton section to cli reference docs --- docs/cli.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 7d4340c5..aa23399a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -223,7 +223,9 @@ uvx balatrobot serve --fast uvx balatrobot serve --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib" ``` -### Linux (Steam/Proton) Platform +### Linux (Steam/Proton) Platform (Experimental) + +This platform is in development, and you may encounter bugs. If you do intend to run BalatroBot on Steam Deck/Proton, please report any issues you encounter. The `linux` platform launches Balatro via Steam's Proton compatibility layer. The CLI auto-detects Steam, Proton, and Balatro paths across all Steam library folders. From 19c8b8a4a82178140bfc72dbbfb4f8443ee6a511 Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Sun, 29 Mar 2026 23:02:52 -0400 Subject: [PATCH 10/21] docs: fix contributing doc --- docs/contributing.md | 49 ++++++++++++++------------------------------ 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 607d97ae..9fe1e3d5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,6 +2,16 @@ Guide for contributing to BalatroBot development. +!!! warning "Help Needed: Linux (Proton) Support" + + CLI support for **Linux (Proton)** is in development. Contributions to implement this platform are highly welcome! + + Please refer to the existing implementations for guidance: + + - **macOS:** `src/balatrobot/platforms/macos.py` + - **Windows:** `src/balatrobot/platforms/windows.py` + - **Linux (Native):** `src/balatrobot/platforms/native.py` + ## Prerequisites - **Balatro** (v1.0.1+) - Purchase from [Steam](https://store.steampowered.com/app/2379780/Balatro/) @@ -74,7 +84,7 @@ Then update the `workspace.library` paths in `.luarc.json` — the Steamodded `l Your `smods` directory may include a version suffix (e.g. `smods-1.0.0-beta-1503a`). Use the actual directory name on your system. -**Example `.luarc.json` (macOS):** +**Example `.luarc.json`:** ```json { @@ -82,38 +92,8 @@ Then update the `workspace.library` paths in `.luarc.json` — the Steamodded `l "workspace": { "library": [ "/path/to/Balatro/Mods/smods/lsp_def", - ".lua-lsp/love2d/library", - ".lua-lsp/luasocket/library", - "src/lua" - ] - }, - "diagnostics": { - "disable": [ - "lowercase-global" - ], - "globals": [ - "G", - "BB_GAMESTATE", - "BB_ERROR_NAMES", - "BB_ENDPOINTS" - ] - }, - "type": { - "weakUnionCheck": true - } -} -``` - -**Example `.luarc.json` (Linux/Proton):** - -```json -{ - "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", - "workspace": { - "library": [ - "~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/smods/lsp_def", - ".lua-lsp/love2d/library", - ".lua-lsp/luasocket/library", + "/path/to/love2d/library", + "/path/to/luasocket/library", "src/lua" ] }, @@ -181,7 +161,7 @@ make install Activate the virtual environment to use the `balatrobot` command: -**macOS/Linux:** +**macOS/Linux (Native or Proton):** ```bash source .venv/bin/activate @@ -213,6 +193,7 @@ Tests use Python + pytest to communicate with the Lua API. You don't need to hav ```bash # Run all tests (runs CLI and Lua suites separately) +# If on Proton/Steam Deck use: uv run ./scripts/dev.sh test make test # Run Lua tests (parallel execution recommended) From ceeaabfe8c8c0c44ce247a2e510c4891d3a5b8c3 Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Mon, 30 Mar 2026 00:20:24 -0400 Subject: [PATCH 11/21] fix(platforms): fix import error on platforms/linux.py --- src/balatrobot/platforms/linux.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py index 8477fe8c..a4e79432 100644 --- a/src/balatrobot/platforms/linux.py +++ b/src/balatrobot/platforms/linux.py @@ -7,11 +7,11 @@ import re from pathlib import Path -logger = logging.getLogger(__name__) - from balatrobot.config import Config from balatrobot.platforms.base import BaseLauncher +logger = logging.getLogger(__name__) + BALATRO_APP_ID = "2379780" _STEAM_ROOT_CANDIDATES = [ From ea511a81e45b37aea73ab5138548b4064db7e7ec Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Mon, 30 Mar 2026 00:23:37 -0400 Subject: [PATCH 12/21] fix(test-platforms): fix unused value error in test_platforms.py --- tests/cli/test_platforms.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index 1e786ec1..2518b3f8 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -463,14 +463,15 @@ def test_detects_x0_socket(self, tmp_path, monkeypatch): (x11_dir / "X0").touch() monkeypatch.setattr( "balatrobot.platforms.linux._detect_display", - lambda: _detect_display.__wrapped__(x11_dir) - if hasattr(_detect_display, "__wrapped__") - else None, + lambda: ( + _detect_display.__wrapped__(x11_dir) + if hasattr(_detect_display, "__wrapped__") + else None + ), ) # Test the function directly with a patched path import balatrobot.platforms.linux as linux_mod - original_path = Path("/tmp/.X11-unix") monkeypatch.setattr( linux_mod, "_detect_display", lambda: ":0" if x11_dir.exists() else None ) @@ -547,9 +548,7 @@ def test_auto_detects_display_when_unset(self, tmp_path, monkeypatch): "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] ) monkeypatch.delenv("DISPLAY", raising=False) - monkeypatch.setattr( - "balatrobot.platforms.linux._detect_display", lambda: ":0" - ) + monkeypatch.setattr("balatrobot.platforms.linux._detect_display", lambda: ":0") launcher = LinuxLauncher() config = Config() @@ -614,9 +613,7 @@ def test_no_display_detected_omits_key(self, tmp_path, monkeypatch): "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] ) monkeypatch.delenv("DISPLAY", raising=False) - monkeypatch.setattr( - "balatrobot.platforms.linux._detect_display", lambda: None - ) + monkeypatch.setattr("balatrobot.platforms.linux._detect_display", lambda: None) launcher = LinuxLauncher() config = Config() @@ -632,9 +629,7 @@ def test_warns_when_display_not_detected(self, tmp_path, monkeypatch, caplog): "balatrobot.platforms.linux._STEAM_ROOT_CANDIDATES", [steam] ) monkeypatch.delenv("DISPLAY", raising=False) - monkeypatch.setattr( - "balatrobot.platforms.linux._detect_display", lambda: None - ) + monkeypatch.setattr("balatrobot.platforms.linux._detect_display", lambda: None) launcher = LinuxLauncher() config = Config() From bfe9a18ee166ed07d9252c3e1ca62624b7ba656e Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Mon, 30 Mar 2026 00:10:46 -0400 Subject: [PATCH 13/21] docs(dev): add clarity on external tool config: stylua and lua-language-server --- docs/contributing.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 9fe1e3d5..54be334c 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -51,6 +51,10 @@ export BALATROBOT_AUDIO=0 **Setup:** Install [direnv](https://direnv.net/), then create `.envrc` in the project root with the above configuration, updating paths for your system. +### External Development Tools + +`uv sync --group dev --group test` installs Python dependencies only. Non-Python tools such as `stylua` and `lua-language-server` are not managed by `uv` and must be installed separately if you want Lua formatting and Lua type checking. + ### Lua LSP Configuration The `.luarc.json` file should be placed at the root of the balatrobot repository. It configures the Lua Language Server for IDE support (autocomplete, diagnostics, type checking). @@ -73,12 +77,12 @@ git clone --depth 1 https://github.com/LuaCATS/luasocket.git .lua-lsp/luasocket Then update the `workspace.library` paths in `.luarc.json` — the Steamodded `lsp_def` path varies by platform: -| Platform | Steamodded `lsp_def` path | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| macOS | `~/Library/Application Support/Balatro/Mods/smods/lsp_def` | -| Windows | `%AppData%/Balatro/Mods/smods/lsp_def` | -| Linux (Steam/Proton) | `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/smods/lsp_def` | -| Linux (Native) | `~/.config/love/Mods/smods/lsp_def` | +| Platform | Steamodded `lsp_def` path | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| macOS | `~/Library/Application Support/Balatro/Mods/smods/lsp_def` | +| Windows | `%AppData%/Balatro/Mods/smods/lsp_def` | +| Linux (Steam/Proton) | `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/smods/lsp_def` | +| Linux (Native) | `~/.config/love/Mods/smods/lsp_def` | !!! note "Steamodded version suffix" From 09e91ffb6b85414ce66b6ed767c8525adce179e3 Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Mon, 30 Mar 2026 00:38:48 -0400 Subject: [PATCH 14/21] fix(dev): avoid terminal hang in dev.sh fixtures() --- scripts/dev.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 3ff78f9b..9e962c51 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -75,10 +75,11 @@ cmd_quality() { } cmd_fixtures() { - print_msg "${YELLOW}Starting Balatro...${RESET}" - balatrobot --fast --debug print_msg "${YELLOW}Generating all fixtures...${RESET}" - python tests/fixtures/generate.py + if ! python tests/fixtures/generate.py; then + print_msg "${RED}Fixture generation failed. Make sure BalatroBot is already running and reachable, then try again.${RESET}" + return 1 + fi } cmd_test() { From 1ba49d0b72c42788411ccbb0eaf1ee7c425d8a73 Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Mon, 30 Mar 2026 00:48:19 -0400 Subject: [PATCH 15/21] chore(dev): added stop command to cleanup stale balatro instances --- scripts/dev.sh | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 9e962c51..2924ff9d 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -8,6 +8,7 @@ set -euo pipefail YELLOW='\033[33m' GREEN='\033[32m' BLUE='\033[34m' +RED='\033[31m' RESET='\033[0m' MAX_XDIST="${MAX_XDIST:-6}" @@ -26,6 +27,7 @@ cmd_help() { printf " ${GREEN}%-18s${RESET} %s\n" "typecheck" "Run type checkers (Python and Lua)" printf " ${GREEN}%-18s${RESET} %s\n" "quality" "Run all code quality checks" printf " ${GREEN}%-18s${RESET} %s\n" "fixtures" "Generate fixtures" + printf " ${GREEN}%-18s${RESET} %s\n" "stop" "Stop BalatroBot and Balatro processes" printf " ${GREEN}%-18s${RESET} %s\n" "test" "Run all tests" printf " ${GREEN}%-18s${RESET} %s\n" "all" "Run all code quality checks and tests" } @@ -82,6 +84,24 @@ cmd_fixtures() { fi } +cmd_stop() { + local stopped=0 + + if pkill -f "balatrobot serve" >/dev/null 2>&1; then + print_msg "${YELLOW}Stopped BalatroBot serve process(es).${RESET}" + stopped=1 + fi + + if pkill -f "Balatro.exe" >/dev/null 2>&1; then + print_msg "${YELLOW}Stopped Balatro game process(es).${RESET}" + stopped=1 + fi + + if [ "$stopped" -eq 0 ]; then + print_msg "${BLUE}No BalatroBot or Balatro processes found.${RESET}" + fi +} + cmd_test() { print_msg "${YELLOW}Running tests/cli with 2 workers...${RESET}" pytest -n 2 tests/cli @@ -99,11 +119,11 @@ cmd_all() { target="${1:-help}" case "$target" in - help|install|lint|format|typecheck|quality|fixtures|test|all) + help|install|lint|format|typecheck|quality|fixtures|stop|test|all) "cmd_${target}" ;; *) - print_msg "\033[31mUnknown target: ${target}${RESET}" + print_msg "${RED}Unknown target: ${target}${RESET}" cmd_help exit 1 ;; From 3f2a1632099a6d181363a288167ac82c3feaaa58 Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Mon, 30 Mar 2026 00:53:27 -0400 Subject: [PATCH 16/21] refactor(bot): ran code quality on bot.py --- examples/bot.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/examples/bot.py b/examples/bot.py index 0b494d83..b5086427 100644 --- a/examples/bot.py +++ b/examples/bot.py @@ -10,14 +10,18 @@ # BalatroBot API endpoint URL = "http://127.0.0.1:12346" + def rpc(method: str, params: dict = {}) -> dict: """Send a JSON-RPC 2.0 request to the BalatroBot API.""" - response = requests.post(URL, json={ - "jsonrpc": "2.0", - "method": method, - "params": params, - "id": 1, - }) + response = requests.post( + URL, + json={ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + }, + ) data = response.json() # Raise if error, otherwise return result (contains game state) if "error" in data: From c1878b5a71bbc52e5bb1f7ce7c235ed281b9e850 Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Mon, 30 Mar 2026 01:38:11 -0400 Subject: [PATCH 17/21] fix(dev): fix workers limit in dev.sh lua tests --- scripts/dev.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 2924ff9d..400c3452 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -12,7 +12,9 @@ RED='\033[31m' RESET='\033[0m' MAX_XDIST="${MAX_XDIST:-6}" +CLI_XDIST_WORKERS="${CLI_XDIST_WORKERS:-2}" XDIST_WORKERS=$(python -c "import multiprocessing as mp; print(min(mp.cpu_count(), $MAX_XDIST))") +LUA_XDIST_WORKERS=$(python -c "print(max($XDIST_WORKERS - $CLI_XDIST_WORKERS, 1))") print_msg() { printf "%b\n" "$1"; } @@ -103,10 +105,10 @@ cmd_stop() { } cmd_test() { - print_msg "${YELLOW}Running tests/cli with 2 workers...${RESET}" - pytest -n 2 tests/cli - print_msg "${YELLOW}Running tests/lua with ${XDIST_WORKERS} workers...${RESET}" - pytest -n "${XDIST_WORKERS}" tests/lua + print_msg "${YELLOW}Running tests/cli with ${CLI_XDIST_WORKERS} workers...${RESET}" + pytest -n "${CLI_XDIST_WORKERS}" tests/cli + print_msg "${YELLOW}Running tests/lua with ${LUA_XDIST_WORKERS} workers...${RESET}" + pytest -n "${LUA_XDIST_WORKERS}" tests/lua } cmd_all() { From 6a29ef8aac17b500efe11baa98afa4ebb97716c2 Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Mon, 30 Mar 2026 02:02:36 -0400 Subject: [PATCH 18/21] docs: fix missing warning text in contributing doc --- docs/contributing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contributing.md b/docs/contributing.md index 54be334c..203b4e76 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -26,6 +26,8 @@ Guide for contributing to BalatroBot development. We use [direnv](https://direnv.net/) to automatically manage environment variables and virtual environment activation. When you `cd` into the project directory, direnv automatically loads settings from `.envrc`. +!!! warning "Contains Secrets" + The `.envrc` file may contain API keys and tokens. **Never commit this file**. **Example `.envrc` configuration:** From 1645dcf5bebeb38c17bfdc80b8c25e61af2182bd Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Mon, 30 Mar 2026 02:06:23 -0400 Subject: [PATCH 19/21] docs: add space back to warning in docs/contributing --- docs/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.md b/docs/contributing.md index 203b4e76..0317040d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -28,7 +28,7 @@ We use [direnv](https://direnv.net/) to automatically manage environment variabl !!! warning "Contains Secrets" -The `.envrc` file may contain API keys and tokens. **Never commit this file**. + The `.envrc` file may contain API keys and tokens. **Never commit this file**. **Example `.envrc` configuration:** From d407b2e8b30d8a3a3ad68a3897e1330a7a044663 Mon Sep 17 00:00:00 2001 From: Charles Merritt Date: Mon, 30 Mar 2026 02:09:49 -0400 Subject: [PATCH 20/21] docs: document new dev.sh stop command helper --- docs/contributing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contributing.md b/docs/contributing.md index 0317040d..3d1d1972 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -254,6 +254,8 @@ On platforms where `make` is not available (e.g. Steam Deck's read-only OS), use ./scripts/dev.sh typecheck # Run type checkers ./scripts/dev.sh quality # Run all quality checks ./scripts/dev.sh test # Run all tests +./scripts/dev.sh fixtures # Generate test fixtures +./scripts/dev.sh stop # Kill all BalatroBot and Balatro processes ./scripts/dev.sh all # Run quality checks + tests ``` From ca552284b4d926c5647a5aadb595ced82f766de8 Mon Sep 17 00:00:00 2001 From: Chaz Merritt Date: Mon, 30 Mar 2026 17:44:43 -0400 Subject: [PATCH 21/21] fix(dev): lower lua workers further --- scripts/dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 400c3452..1d84c6cb 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -14,7 +14,7 @@ RESET='\033[0m' MAX_XDIST="${MAX_XDIST:-6}" CLI_XDIST_WORKERS="${CLI_XDIST_WORKERS:-2}" XDIST_WORKERS=$(python -c "import multiprocessing as mp; print(min(mp.cpu_count(), $MAX_XDIST))") -LUA_XDIST_WORKERS=$(python -c "print(max($XDIST_WORKERS - $CLI_XDIST_WORKERS, 1))") +LUA_XDIST_WORKERS="${LUA_XDIST_WORKERS:-2}" print_msg() { printf "%b\n" "$1"; }