From c501b89aa40786af897273d500ffbec8b1ad62ef Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 17:58:27 +0200 Subject: [PATCH 01/60] chore; add .vscode/cursor settings for pytest --- .vscode/settings.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4911468 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} From 11e0af47147a40f8828543f6cfb1176f2f49ddf7 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 17:59:36 +0200 Subject: [PATCH 02/60] chore: add pytest to dev deps --- pyproject.toml | 7 ++++++- uv.lock | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e4c298..d9ca94d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,4 +38,9 @@ lint.task-tags = ["FIX", "TODO", "HACK", "WARN", "PERF", "NOTE", "TEST"] typeCheckingMode = "basic" [dependency-groups] -dev = ["basedpyright>=1.29.5", "mkdocs-material>=9.6.15", "ruff>=0.12.2"] +dev = [ + "basedpyright>=1.29.5", + "mkdocs-material>=9.6.15", + "pytest>=8.4.1", + "ruff>=0.12.2", +] diff --git a/uv.lock b/uv.lock index 7346968..016bdf3 100644 --- a/uv.lock +++ b/uv.lock @@ -34,6 +34,7 @@ source = { editable = "." } dev = [ { name = "basedpyright" }, { name = "mkdocs-material" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -43,6 +44,7 @@ dev = [ dev = [ { name = "basedpyright", specifier = ">=1.29.5" }, { name = "mkdocs-material", specifier = ">=9.6.15" }, + { name = "pytest", specifier = ">=8.4.1" }, { name = "ruff", specifier = ">=0.12.2" }, ] @@ -144,6 +146,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -333,6 +344,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload_time = "2025-05-07T22:47:40.376Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -355,6 +375,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload_time = "2025-06-21T17:56:35.356Z" }, ] +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload_time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload_time = "2025-06-18T05:48:03.955Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 548e2ffc56d58553af8c153431628e3d2fd181ac Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 18:00:03 +0200 Subject: [PATCH 03/60] test: add test for connection api and function api --- tests/conftest.py | 62 +++++++++ tests/test_api_functions.py | 266 ++++++++++++++++++++++++++++++++++++ tests/test_connection.py | 164 ++++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_api_functions.py create mode 100644 tests/test_connection.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2feb44f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,62 @@ +"""Shared test configuration and fixtures for BalatroBot API tests.""" + +import json +import socket +from typing import Any, Generator + +import pytest + +# Connection settings +HOST = "127.0.0.1" +PORT = 12346 +TIMEOUT = 30.0 +BUFFER_SIZE = 65536 # 64KB buffer for UDP messages + + +@pytest.fixture +def udp_client() -> Generator[socket.socket, None, None]: + """Create and clean up a UDP client socket. + + Yields: + Configured UDP socket for testing. + """ + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(TIMEOUT) + # Set socket receive buffer size + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BUFFER_SIZE) + yield sock + + +def send_api_message(sock: socket.socket, name: str, arguments: dict) -> None: + """Send a properly formatted JSON API message. + + Args: + sock: Socket to send through. + name: Function name to call. + arguments: Arguments dictionary for the function. + """ + message = {"name": name, "arguments": arguments} + sock.sendto(json.dumps(message).encode(), (HOST, PORT)) + + +def receive_api_message(sock: socket.socket) -> dict[str, Any]: + """Receive a properly formatted JSON API message from the socket. + + Args: + sock: Socket to receive from. + + Returns: + Received message as a dictionary. + """ + data, _ = sock.recvfrom(BUFFER_SIZE) + return json.loads(data.decode().strip()) + + +def teardown_test(sock: socket.socket) -> None: + """Teardown helper to return to menu state after test. + + Args: + sock: Socket to send teardown message through. + """ + send_api_message(sock, "go_to_menu", {}) + receive_api_message(sock) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py new file mode 100644 index 0000000..cbb0f07 --- /dev/null +++ b/tests/test_api_functions.py @@ -0,0 +1,266 @@ +"""Tests for BalatroBot UDP API game functions.""" + +import socket + +import pytest +from conftest import HOST, PORT, receive_api_message, send_api_message, teardown_test + +from balatrobot.enums import State + + +def test_get_game_state_response(udp_client: socket.socket) -> None: + """Test get_game_state message returns valid JSON game state.""" + send_api_message(udp_client, "get_game_state", {}) + + game_state = receive_api_message(udp_client) + assert isinstance(game_state, dict) + + +def test_game_state_structure(udp_client: socket.socket) -> None: + """Test that game state contains expected top-level fields.""" + send_api_message(udp_client, "get_game_state", {}) + + game_state = receive_api_message(udp_client) + + assert isinstance(game_state, dict) + + expected_keys = {"state", "game"} + assert expected_keys.issubset(game_state.keys()) + assert isinstance(game_state["state"], int) + assert isinstance(game_state["game"], (dict, type(None))) + + +def test_start_run(udp_client: socket.socket) -> None: + """Test starting a run and verifying the state.""" + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "EXAMPLE", + } + send_api_message(udp_client, "start_run", start_run_args) + game_state = receive_api_message(udp_client) + + assert game_state["state"] == State.BLIND_SELECT.value + + teardown_test(udp_client) + + +def test_start_run_with_challenge(udp_client: socket.socket) -> None: + """Test starting a run with a challenge.""" + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": "The Omelette", + "seed": "CHALLENGE_TEST", + } + send_api_message(udp_client, "start_run", start_run_args) + game_state = receive_api_message(udp_client) + + assert game_state["state"] == State.BLIND_SELECT.value + assert len(game_state["jokers"]) == 5 # jokers in The Omelette challenge + + teardown_test(udp_client) + + +def test_start_run_different_stakes(udp_client: socket.socket) -> None: + """Test starting runs with different stake levels.""" + for stake in [1, 2, 3]: + start_run_args = { + "deck": "Red Deck", + "stake": stake, + "challenge": None, + "seed": f"STAKE_{stake}", + } + send_api_message(udp_client, "start_run", start_run_args) + + game_state = receive_api_message(udp_client) + + assert game_state["state"] == State.BLIND_SELECT.value + + # Go back to menu + teardown_test(udp_client) + + +def test_select_blind(udp_client: socket.socket) -> None: + """Test selecting a blind during the blind selection phase.""" + # First start a run to get to blind select state + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "SELECT_BLIND", + } + send_api_message(udp_client, "start_run", start_run_args) + + # Wait for the run to start and reach blind select state + game_state = receive_api_message(udp_client) + assert game_state["state"] == State.BLIND_SELECT.value + + # Now select the blind + select_blind_args = {"action": "select"} + send_api_message(udp_client, "skip_or_select_blind", select_blind_args) + + # Wait for response after blind selection + game_state = receive_api_message(udp_client) + + # Verify we get a valid game state response + assert game_state["state"] == State.SELECTING_HAND.value + + # Assert that there are 8 cards in the hand + assert len(game_state["hand"]) == 8 + + # Go back to menu + teardown_test(udp_client) + + +def test_skip_blind(udp_client: socket.socket) -> None: + """Test skipping a blind during the blind selection phase.""" + # First start a run to get to blind select state + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "SKIP_BLIND", + } + send_api_message(udp_client, "start_run", start_run_args) + + # Wait for the run to start and reach blind select state + game_state = receive_api_message(udp_client) + assert game_state["state"] == State.BLIND_SELECT.value + + # Now skip the blind + skip_blind_args = {"action": "skip"} + send_api_message(udp_client, "skip_or_select_blind", skip_blind_args) + + # Wait for response after blind skip + game_state = receive_api_message(udp_client) + + # # Verify we get a valid game state response + # assert game_state["state"] == State.BLIND_SELECT.value + + # Go back to menu + teardown_test(udp_client) + + +def test_invalid_blind_action(udp_client: socket.socket) -> None: + """Test that invalid blind action arguments are handled properly.""" + # First start a run to get to blind select state + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "INVALID_ACTION", + } + send_api_message(udp_client, "start_run", start_run_args) + + # Wait for the run to start and reach blind select state + game_state = receive_api_message(udp_client) + assert game_state["state"] == State.BLIND_SELECT.value + + # Send invalid action + invalid_args = {"action": "invalid_action"} + send_api_message(udp_client, "skip_or_select_blind", invalid_args) + + # Should receive error response + error_response = receive_api_message(udp_client) + + # Verify error response + assert isinstance(error_response, dict) + assert "error" in error_response + assert "Invalid action arg" in error_response["error"] + + # Go back to menu + teardown_test(udp_client) + + +def test_game_state_during_run(udp_client: socket.socket) -> None: + """Test getting game state at different points during a run.""" + # Start a run + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "STATE_TEST", + } + send_api_message(udp_client, "start_run", start_run_args) + + # Get initial state + initial_state = receive_api_message(udp_client) + assert initial_state["state"] == State.BLIND_SELECT.value + + # Get game state again to ensure it's consistent + send_api_message(udp_client, "get_game_state", {}) + current_state = receive_api_message(udp_client) + + assert current_state["state"] == State.BLIND_SELECT.value + assert current_state["state"] == initial_state["state"] + + # Go back to menu + teardown_test(udp_client) + + +def test_start_run_missing_required_args(udp_client: socket.socket) -> None: + """Test start_run with missing required arguments.""" + # Missing deck + incomplete_args = { + "stake": 1, + "challenge": None, + "seed": "EXAMPLE", + } + send_api_message(udp_client, "start_run", incomplete_args) + + # Should receive error response + response = receive_api_message(udp_client) + assert isinstance(response, dict) + assert "error" in response + assert "Invalid deck arg" in response["error"] + + +def test_start_run_invalid_deck(udp_client: socket.socket) -> None: + """Test start_run with invalid deck name.""" + invalid_args = { + "deck": "Nonexistent Deck", + "stake": 1, + "challenge": None, + "seed": "EXAMPLE", + } + send_api_message(udp_client, "start_run", invalid_args) + + # Should receive error response + response = receive_api_message(udp_client) + assert isinstance(response, dict) + assert "error" in response + assert "Invalid deck arg" in response["error"] + + +def test_go_to_menu(udp_client: socket.socket) -> None: + """Test going to the main menu.""" + send_api_message(udp_client, "go_to_menu", {}) + + game_state = receive_api_message(udp_client) + assert game_state["state"] == State.MENU.value + + +def test_go_to_menu_from_run(udp_client: socket.socket) -> None: + """Test going to menu from within a run.""" + # First start a run + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "MENU_TEST", + } + send_api_message(udp_client, "start_run", start_run_args) + + # Wait for the run to start + initial_state = receive_api_message(udp_client) + assert initial_state["state"] == State.BLIND_SELECT.value + + # Now go to menu + send_api_message(udp_client, "go_to_menu", {}) + + # Wait for the game to confirm we've reached the menu + menu_state = receive_api_message(udp_client) + + assert menu_state["state"] == State.MENU.value diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..d65f9e7 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,164 @@ +"""Tests for BalatroBot UDP API connection and protocol handling.""" + +import json +import socket + +import pytest +from conftest import BUFFER_SIZE, HOST, PORT, TIMEOUT, send_api_message + + +def test_basic_connection(udp_client: socket.socket) -> None: + """Test basic UDP connection and response.""" + send_api_message(udp_client, "get_game_state", {}) + + data, addr = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + + game_state = json.loads(response) + assert isinstance(game_state, dict) + assert addr[0] == HOST + + +def test_rapid_messages(udp_client: socket.socket) -> None: + """Test rapid succession of get_game_state messages.""" + responses = [] + + for _ in range(3): + send_api_message(udp_client, "get_game_state", {}) + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + game_state = json.loads(response) + responses.append(game_state) + + assert all(isinstance(resp, dict) for resp in responses) + assert len(responses) == 3 + + +def test_connection_timeout() -> None: + """Test behavior when no server is listening.""" + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(0.2) + message = {"name": "get_game_state", "arguments": json.dumps({})} + sock.sendto(json.dumps(message).encode(), (HOST, 12345)) # Unused port + + with pytest.raises(socket.timeout): + sock.recvfrom(1024) + + +def test_invalid_json_message(udp_client: socket.socket) -> None: + """Test that invalid JSON messages return error responses.""" + # Send invalid JSON + udp_client.sendto(b"invalid json", (HOST, PORT)) + + # Should receive error response for invalid JSON + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + error_response = json.loads(response) + assert isinstance(error_response, dict) + assert "error" in error_response + assert "Invalid JSON" in error_response["error"] + + # Verify server is still responsive + send_api_message(udp_client, "get_game_state", {}) + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + + # Should still get valid JSON response + game_state = json.loads(response) + assert isinstance(game_state, dict) + + +def test_missing_name_field(udp_client: socket.socket) -> None: + """Test message without name field returns error response.""" + message = {"arguments": json.dumps({})} + udp_client.sendto(json.dumps(message).encode(), (HOST, PORT)) + + # Should receive error response for missing name field + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + error_response = json.loads(response) + assert isinstance(error_response, dict) + assert "error" in error_response + assert "Message must contain a name" in error_response["error"] + + # Verify server is still responsive + send_api_message(udp_client, "get_game_state", {}) + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + + # Should still get valid JSON response + game_state = json.loads(response) + assert isinstance(game_state, dict) + + +def test_missing_arguments_field(udp_client: socket.socket) -> None: + """Test message without arguments field returns error response.""" + message = {"name": "get_game_state"} + udp_client.sendto(json.dumps(message).encode(), (HOST, PORT)) + + # Should receive error response for missing arguments field + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + error_response = json.loads(response) + assert isinstance(error_response, dict) + assert "error" in error_response + assert "Message must contain arguments" in error_response["error"] + + # Verify server is still responsive + send_api_message(udp_client, "get_game_state", {}) + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + + # Should still get valid JSON response + game_state = json.loads(response) + assert isinstance(game_state, dict) + + +def test_unknown_message(udp_client: socket.socket) -> None: + """Test that unknown messages return error responses.""" + # Send unknown message + send_api_message(udp_client, "unknown_function", {}) + + # Should receive error response for unknown function + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + error_response = json.loads(response) + assert isinstance(error_response, dict) + assert "error" in error_response + assert "Unknown function name" in error_response["error"] + + # Verify server is still responsive + send_api_message(udp_client, "get_game_state", {}) + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + + # Should still get valid JSON response + game_state = json.loads(response) + assert isinstance(game_state, dict) + + +def test_large_message_handling(udp_client: socket.socket) -> None: + """Test handling of large messages within UDP limits.""" + # Create a large but valid message + large_args = {"data": "x" * 1000} # 1KB of data + send_api_message(udp_client, "get_game_state", large_args) + + # Should still get a response + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + game_state = json.loads(response) + assert isinstance(game_state, dict) + + +def test_empty_message(udp_client: socket.socket) -> None: + """Test sending an empty message.""" + udp_client.sendto(b"", (HOST, PORT)) + + # Verify server is still responsive + send_api_message(udp_client, "get_game_state", {}) + data, _ = udp_client.recvfrom(BUFFER_SIZE) + response = data.decode().strip() + + # Should still get valid JSON response + game_state = json.loads(response) + assert isinstance(game_state, dict) From 1d893b5ecf02c5d90e500f926a74579dcf8279eb Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 18:06:59 +0200 Subject: [PATCH 04/60] feat: init lua code socket server --- src/lua/api.lua | 391 ++++++++++++++----------- src/lua/bot.lua | 333 --------------------- src/lua/hook.lua | 152 ---------- src/lua/list.lua | 44 --- src/lua/middleware.lua | 638 ----------------------------------------- src/lua/utils.lua | 345 +++++++--------------- 6 files changed, 318 insertions(+), 1585 deletions(-) delete mode 100644 src/lua/bot.lua delete mode 100644 src/lua/hook.lua delete mode 100644 src/lua/list.lua delete mode 100644 src/lua/middleware.lua diff --git a/src/lua/api.lua b/src/lua/api.lua index 1f4a446..a9494f3 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -1,198 +1,247 @@ local socket = require("socket") local json = require("json") -local data, msg_or_ip, port_or_nil - -BalatrobotAPI = {} -BalatrobotAPI.socket = nil - -BalatrobotAPI.waitingFor = nil -BalatrobotAPI.waitingForAction = true - --- Action queues for Python bot commands -BalatrobotAPI.q_skip_or_select_blind = nil -BalatrobotAPI.q_select_cards_from_hand = nil -BalatrobotAPI.q_select_shop_action = nil -BalatrobotAPI.q_select_booster_action = nil -BalatrobotAPI.q_sell_jokers = nil -BalatrobotAPI.q_rearrange_jokers = nil -BalatrobotAPI.q_use_or_sell_consumables = nil -BalatrobotAPI.q_rearrange_consumables = nil -BalatrobotAPI.q_rearrange_hand = nil -BalatrobotAPI.q_start_run = nil - -function BalatrobotAPI.notifyapiclient() - -- Generate gamestate json object - local _gamestate = Utils.getGamestate() - _gamestate.waitingFor = BalatrobotAPI.waitingFor - sendDebugMessage("WaitingFor " .. tostring(BalatrobotAPI.waitingFor)) - _gamestate.waitingForAction = BalatrobotAPI.waitingFor ~= nil and BalatrobotAPI.waitingForAction or false - local _gamestateJsonString = json.encode(_gamestate) - - if BalatrobotAPI.socket and port_or_nil ~= nil then - sendDebugMessage(_gamestate.waitingFor) - BalatrobotAPI.socket:sendto(string.format("%s", _gamestateJsonString), msg_or_ip, port_or_nil) - end -end - -function BalatrobotAPI.respond(str) - sendDebugMessage("respond") - if BalatrobotAPI.socket and port_or_nil ~= nil then - response = {} - response.response = str - str = json.encode(response) - BalatrobotAPI.socket:sendto(string.format("%s\n", str), msg_or_ip, port_or_nil) +API = {} +API.socket = nil +API.functions = {} +API.pending_requests = {} +API.last_client_ip = nil +API.last_client_port = nil + +-------------------------------------------------------------------------------- +-- Update Loop +-------------------------------------------------------------------------------- + +function API.update(dt) + -- Create socket if it doesn't exist + if not API.socket then + API.socket = socket.udp() + API.socket:settimeout(0) + local port = BALATRO_BOT_CONFIG.port + API.socket:setsockname("127.0.0.1", tonumber(port)) + sendDebugMessage("UDP socket created on port " .. port, "BALATROBOT") end -end - -function BalatrobotAPI.queueaction(action) - local _params = Bot.ACTIONPARAMS[action[1]] - List.pushleft(BalatrobotAPI["q_" .. _params.func], { 0, action }) -end -function BalatrobotAPI.update(dt) - if not BalatrobotAPI.socket then - BalatrobotAPI.socket = socket.udp() - BalatrobotAPI.socket:settimeout(0) - local port = BALATRO_BOT_CONFIG.port - BalatrobotAPI.socket:setsockname("127.0.0.1", tonumber(port)) - sendDebugMessage("New socket created on port " .. port) + -- Process pending requests + for key, request in pairs(API.pending_requests) do + if request.condition(request.args) then + request.action(request.args) + API.pending_requests[key] = nil + end end - data, msg_or_ip, port_or_nil = BalatrobotAPI.socket:receivefrom() - if data then - if data == "HELLO\n" or data == "HELLO" then - BalatrobotAPI.notifyapiclient() + -- Parse received data and run the appropriate function + local raw_data, client_ip, client_port = API.socket:receivefrom(65536) + if raw_data and client_ip and client_port then + -- Store the last client connection + API.last_client_ip = client_ip + API.last_client_port = client_port + + sendDebugMessage("Received data from " .. client_ip .. ":" .. client_port, "BALATROBOT") + local ok, data = pcall(json.decode, raw_data) + if not ok then + sendErrorMessage("Invalid JSON", "BALATROBOT") + API.send_response({ error = "Invalid JSON" }) + return + end + if data.name == nil then + sendErrorMessage("Message must contain a name", "BALATROBOT") + API.send_response({ error = "Message must contain a name" }) + elseif data.arguments == nil then + sendErrorMessage("Message must contain arguments", "BALATROBOT") + API.send_response({ error = "Message must contain arguments" }) else - local _action = Utils.parseaction(data) - local _err = Utils.validateAction(_action) - - if _err == Utils.ERROR.NUMPARAMS then - BalatrobotAPI.respond("Error: Incorrect number of params for action " .. _action[1]) - elseif _err == Utils.ERROR.MSGFORMAT then - BalatrobotAPI.respond("Error: Incorrect message format. Should be ACTION|arg1|arg2") - elseif _err == Utils.ERROR.INVALIDACTION then - BalatrobotAPI.respond("Error: Action invalid for action " .. _action[1]) + local func = API.functions[data.name] + local args = data.arguments + if func == nil then + sendErrorMessage("Unknown function name: " .. data.name, "BALATROBOT") + API.send_response({ error = "Unknown function name: " .. data.name }) + elseif type(args) ~= "table" then + sendErrorMessage("Arguments must be a table", "BALATROBOT") + API.send_response({ error = "Arguments must be a table: " .. type(args) }) else - BalatrobotAPI.waitingForAction = false - BalatrobotAPI.queueaction(_action) + func(args) end end - elseif msg_or_ip ~= "timeout" then - sendDebugMessage("Unknown network error: " .. tostring(msg)) + elseif client_ip ~= "timeout" then + sendErrorMessage("UDP error: " .. tostring(client_ip), "BALATROBOT") end +end - -- No idea if this is necessary - -- Without this being commented out, FPS capped out at ~80 for me - -- socket.sleep(0.01) -end - -function BalatrobotAPI.init() - -- Initialize action queues for Python bot commands - BalatrobotAPI.q_skip_or_select_blind = List.new() - BalatrobotAPI.q_select_cards_from_hand = List.new() - BalatrobotAPI.q_select_shop_action = List.new() - BalatrobotAPI.q_select_booster_action = List.new() - BalatrobotAPI.q_sell_jokers = List.new() - BalatrobotAPI.q_rearrange_jokers = List.new() - BalatrobotAPI.q_use_or_sell_consumables = List.new() - BalatrobotAPI.q_rearrange_consumables = List.new() - BalatrobotAPI.q_rearrange_hand = List.new() - BalatrobotAPI.q_start_run = List.new() - - love.update = Hook.addcallback(love.update, BalatrobotAPI.update) - - -- Tell the game engine that every frame is 8/60 seconds long - -- Speeds up the game execution - -- Values higher than this seem to cause instability - if BALATRO_BOT_CONFIG.dt then - love.update = Hook.addbreakpoint(love.update, function(dt) - return BALATRO_BOT_CONFIG.dt - end) +function API.send_response(response) + if API.last_client_ip and API.last_client_port then + API.socket:sendto(json.encode(response), API.last_client_ip, API.last_client_port) end +end - -- Disable FPS cap - if BALATRO_BOT_CONFIG.uncap_fps then - G.FPS_CAP = 999999.0 - end +function API.init() + -- API.setup_game_hooks() - -- Makes things move instantly instead of sliding - if BALATRO_BOT_CONFIG.instant_move then - function Moveable.move_xy(self, dt) - -- Directly set the visible transform to the target transform - self.VT.x = self.T.x - self.VT.y = self.T.y - end + -- Hook into the game's update loop + local original_update = love.update + love.update = function(dt) + original_update(dt) + API.update(dt) end - -- Forcibly disable vsync - if BALATRO_BOT_CONFIG.disable_vsync then - love.window.setVSync(0) - end + sendInfoMessage("BalatrobotAPI initialized", "BALATROBOT") +end + +-------------------------------------------------------------------------------- +-- API Functions +-------------------------------------------------------------------------------- - -- Disable card scoring animation text - if BALATRO_BOT_CONFIG.disable_card_eval_status_text then - card_eval_status_text = function(card, eval_type, amt, percent, dir, extra) end +API.functions["get_game_state"] = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) +end + +API.functions["go_to_menu"] = function(args) + if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then + sendDebugMessage("go_to_menu called but already in menu", "BALATROBOT") + local game_state = utils.get_game_state() + API.send_response(game_state) + return end - -- Only draw/present every Nth frame - local original_draw = love.draw - local draw_count = 0 - love.draw = function() - draw_count = draw_count + 1 - if draw_count % BALATRO_BOT_CONFIG.frame_ratio == 0 then - original_draw() + G.FUNCS.go_to_menu({}) + API.pending_requests["go_to_menu"] = { + condition = function() + return G.STATE == G.STATES.MENU and G.MAIN_MENU_UI + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } +end + +API.functions["start_run"] = function(args) + -- Reset the game + local play_button = G.MAIN_MENU_UI:get_UIE_by_ID("main_menu_play") + G.FUNCS[play_button.config.button]({ config = {} }) + G.FUNCS.exit_overlay_menu({}) + + -- Set the deck + local deck_found = false + for _, v in pairs(G.P_CENTER_POOLS.Back) do + if v.name == args.deck then + sendDebugMessage("Changing to deck: " .. v.name, "BALATROBOT") + G.GAME.selected_back:change_to(v) + G.GAME.viewed_back:change_to(v) + deck_found = true + break end end + if not deck_found then + sendErrorMessage("Invalid deck arg for start_run: " .. tostring(args.deck), "BALATROBOT") + API.send_response({ error = "Invalid deck arg for start_run: " .. tostring(args.deck) }) + return + end - local original_present = love.graphics.present - love.graphics.present = function() - if draw_count % BALATRO_BOT_CONFIG.frame_ratio == 0 then - original_present() + -- Set the challenge + local challenge_obj = nil + if args.challenge then + for i = 1, #G.CHALLENGES do + if G.CHALLENGES[i].name == args.challenge then + challenge_obj = G.CHALLENGES[i] + break + end end end + G.GAME.challenge_name = args.challenge + + -- Start the run + G.FUNCS.start_run(nil, { stake = args.stake, seed = args.seed, challenge = challenge_obj }) + + -- Defer sending response until the run has started + API.pending_requests["start_run"] = { + condition = function() + return G.STATE == G.STATES.BLIND_SELECT and G.GAME.blind_on_deck + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } +end + +API.functions["skip_or_select_blind"] = function(args) + local current_blind = G.GAME.blind_on_deck + local blind_obj = G.blind_select_opts[string.lower(current_blind)] + if args.action == "select" then + button = blind_obj:get_UIE_by_ID("select_blind_button") + G.FUNCS[button.config.button](button) + API.pending_requests["skip_or_select_blind"] = { + condition = function() + return G.GAME and G.GAME.facing_blind and G.STATE == G.STATES.SELECTING_HAND + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } + elseif args.action == "skip" then + button = blind_obj:get_UIE_by_ID("tag_" .. current_blind).children[2] + G.FUNCS[button.config.button](button) + API.pending_requests["skip_or_select_blind"] = { + condition = function() + local prev_state = { + ["Small"] = G.prev_small_state, + ["Large"] = G.prev_large_state, + ["Boss"] = G.prev_boss_state, + } + return prev_state[current_blind] == "Skipped" + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } + else + sendErrorMessage("Invalid action arg for skip_or_select_blind: " .. args.action, "BALATROBOT") + API.send_response({ error = "Invalid action arg for skip_or_select_blind: " .. args.action }) + return + end +end + +API.functions["play_cards"] = function(args) + -- TODO: implement +end + +API.functions["discard_cards"] = function(args) + -- TODO: implement +end + +API.functions["select_booster_action"] = function(args) + -- TODO: implement +end + +API.functions["select_shop_action"] = function(args) + -- TODO: implement +end + +API.functions["rearrange_hand"] = function(args) + -- TODO: implement +end + +API.functions["rearrange_consumables"] = function(args) + -- TODO: implement +end + +API.functions["rearrange_jokers"] = function(args) + -- TODO: implement +end + +API.functions["use_or_sell_consumables"] = function(args) + -- TODO: implement +end + +API.functions["sell_jokers"] = function(args) + -- TODO: implement +end - -- Set up waiting states for Python bot - Middleware.c_play_hand = Hook.addbreakpoint(Middleware.c_play_hand, function() - BalatrobotAPI.waitingFor = "select_cards_from_hand" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_select_blind = Hook.addbreakpoint(Middleware.c_select_blind, function() - BalatrobotAPI.waitingFor = "skip_or_select_blind" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_choose_booster_cards = Hook.addbreakpoint(Middleware.c_choose_booster_cards, function() - BalatrobotAPI.waitingFor = "select_booster_action" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_shop = Hook.addbreakpoint(Middleware.c_shop, function() - BalatrobotAPI.waitingFor = "select_shop_action" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_rearrange_hand = Hook.addbreakpoint(Middleware.c_rearrange_hand, function() - BalatrobotAPI.waitingFor = "rearrange_hand" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_rearrange_consumables = Hook.addbreakpoint(Middleware.c_rearrange_consumables, function() - BalatrobotAPI.waitingFor = "rearrange_consumables" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_use_or_sell_consumables = Hook.addbreakpoint(Middleware.c_use_or_sell_consumables, function() - BalatrobotAPI.waitingFor = "use_or_sell_consumables" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_rearrange_jokers = Hook.addbreakpoint(Middleware.c_rearrange_jokers, function() - BalatrobotAPI.waitingFor = "rearrange_jokers" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_sell_jokers = Hook.addbreakpoint(Middleware.c_sell_jokers, function() - BalatrobotAPI.waitingFor = "sell_jokers" - BalatrobotAPI.waitingForAction = true - end) - Middleware.c_start_run = Hook.addbreakpoint(Middleware.c_start_run, function() - BalatrobotAPI.waitingFor = "start_run" - BalatrobotAPI.waitingForAction = true - end) -end - -return BalatrobotAPI +return API diff --git a/src/lua/bot.lua b/src/lua/bot.lua deleted file mode 100644 index 86bf60f..0000000 --- a/src/lua/bot.lua +++ /dev/null @@ -1,333 +0,0 @@ -Bot = {} - --- ACTIONS -Bot.ACTIONS = { - SELECT_BLIND = 1, - SKIP_BLIND = 2, - PLAY_HAND = 3, - DISCARD_HAND = 4, - END_SHOP = 5, - REROLL_SHOP = 6, - BUY_CARD = 7, - BUY_VOUCHER = 8, - BUY_BOOSTER = 9, - SELECT_BOOSTER_CARD = 10, - SKIP_BOOSTER_PACK = 11, - SELL_JOKER = 12, - USE_CONSUMABLE = 13, - SELL_CONSUMABLE = 14, - REARRANGE_JOKERS = 15, - REARRANGE_CONSUMABLES = 16, - REARRANGE_HAND = 17, - PASS = 18, - START_RUN = 19, -} - --- ACTION PARAMETERS -Bot.ACTIONPARAMS = { - [Bot.ACTIONS.SELECT_BLIND] = { - num_args = 1, - func = "skip_or_select_blind", - isvalid = function(action) - if G.STATE == G.STATES.BLIND_SELECT then - return true - end - return false - end, - }, - [Bot.ACTIONS.SKIP_BLIND] = { - num_args = 1, - func = "skip_or_select_blind", - isvalid = function(action) - if G.STATE == G.STATES.BLIND_SELECT then - return true - end - return false - end, - }, - [Bot.ACTIONS.PLAY_HAND] = { - num_args = 2, - func = "select_cards_from_hand", - isvalid = function(action) - if - G - and G.GAME - and G.GAME.current_round - and G.hand - and G.hand.cards - and G.GAME.current_round.hands_left > 0 - and #action == 2 - and Utils.isTableInRange(action[2], 1, #G.hand.cards) - and Utils.isTableUnique(action[2]) - then - return true - end - return false - end, - }, - [Bot.ACTIONS.DISCARD_HAND] = { - num_args = 2, - func = "select_cards_from_hand", - isvalid = function(action) - if - G - and G.GAME - and G.GAME.current_round - and G.hand - and G.hand.cards - and G.GAME.current_round.discards_left > 0 - and #action == 2 - and Utils.isTableInRange(action[2], 1, #G.hand.cards) - and Utils.isTableUnique(action[2]) - then - return true - end - return false - end, - }, - [Bot.ACTIONS.END_SHOP] = { - num_args = 1, - func = "select_shop_action", - isvalid = function(action) - if G and G.STATE == G.STATES.SHOP then - return true - end - return false - end, - }, - [Bot.ACTIONS.REROLL_SHOP] = { - num_args = 1, - func = "select_shop_action", - isvalid = function(action) - if - G - and G.STATE == G.STATES.SHOP - and (G.GAME.dollars - G.GAME.bankrupt_at - G.GAME.current_round.reroll_cost >= 0) - then - return true - end - return false - end, - }, - [Bot.ACTIONS.BUY_CARD] = { - num_args = 2, - func = "select_shop_action", - isvalid = function(action) - if - G - and G.STATE == G.STATES.SHOP - and #action == 2 - and #action[2] == 1 - and G.shop_jokers - and G.shop_jokers.cards - and #G.shop_jokers.cards >= action[2][1] - and (G.GAME.dollars - G.GAME.bankrupt_at - G.shop_jokers.cards[action[2][1]].cost >= 0) - then - return true - end - return false - end, - }, - [Bot.ACTIONS.BUY_VOUCHER] = { - num_args = 2, - func = "select_shop_action", - isvalid = function(action) - if - G - and G.STATE == G.STATES.SHOP - and #action == 2 - and #action[2] == 1 - and G.shop_vouchers - and G.shop_vouchers.cards - and #G.shop_vouchers.cards >= action[2][1] - and (G.GAME.dollars - G.GAME.bankrupt_at - G.shop_vouchers.cards[action[2][1]].cost >= 0) - then - return true - end - return false - end, - }, - [Bot.ACTIONS.BUY_BOOSTER] = { - num_args = 2, - func = "select_shop_action", - isvalid = function(action) - if - G - and G.STATE == G.STATES.SHOP - and #action == 2 - and #action[2] == 1 - and G.shop_booster - and G.shop_booster.cards - and #G.shop_booster.cards >= action[2][1] - and (G.GAME.dollars - G.GAME.bankrupt_at - G.shop_booster.cards[action[2][1]].cost >= 0) - then - return true - end - return false - end, - }, - [Bot.ACTIONS.SELECT_BOOSTER_CARD] = { - num_args = 3, - func = "select_booster_action", - isvalid = function(action) - if - G - and G.hand - and G.pack_cards - and G.hand.cards - and G.pack_cards.cards - and (G.STATE == G.STATES.TAROT_PACK or G.STATE == G.STATES.PLANET_PACK or G.STATE == G.STATES.SPECTRAL_PACK or G.STATE == G.STATES.STANDARD_PACK or G.STATE == G.STATES.BUFFOON_PACK) - and Utils.isTableInRange(action[2], 1, #G.hand.cards) - and Utils.isTableUnique(action[2]) - and Utils.isTableInRange(action[3], 1, #G.pack_cards.cards) - and Utils.isTableUnique(action[3]) - and Middleware.BUTTONS.SKIP_PACK ~= nil - and Middleware.BUTTONS.SKIP_PACK.config.button == "skip_booster" - then - if - G.pack_cards.cards[action[2][1]].ability.consumeable - and G.pack_cards.cards[action[2][1]].ability.consumeable.max_highlighted ~= nil - and #action[3] > 0 - and #action[3] <= G.pack_cards.cards[action[2][1]].ability.consumeable.max_highlighted - then - return true - else - return false - end - return true - end - return false - end, - }, - [Bot.ACTIONS.SKIP_BOOSTER_PACK] = { - num_args = 1, - func = "select_booster_action", - isvalid = function(action) - if - G.pack_cards - and G.pack_cards.cards - and G.pack_cards.cards[1] - and (G.STATE == G.STATES.PLANET_PACK or G.STATE == G.STATES.STANDARD_PACK or G.STATE == G.STATES.BUFFOON_PACK or (G.hand and G.hand.cards[1])) - and Middleware.BUTTONS.SKIP_PACK ~= nil - and Middleware.BUTTONS.SKIP_PACK.config.button == "skip_booster" - then - return true - end - return false - end, - }, - [Bot.ACTIONS.SELL_JOKER] = { - num_args = 2, - func = "sell_jokers", - isvalid = function(action) - if G and G.jokers and G.jokers.cards then - if not action[2] then - return true - end - - if - Utils.isTableInRange(action[2], 1, #G.jokers.cards) - and not G.jokers.cards[action[2][1]].ability.eternal - then - return true - end - end - return false - end, - }, - [Bot.ACTIONS.USE_CONSUMABLE] = { - num_args = 2, - func = "use_or_sell_consumables", - isvalid = function(action) - -- TODO implement this - return true - end, - }, - [Bot.ACTIONS.SELL_CONSUMABLE] = { - num_args = 2, - func = "use_or_sell_consumables", - isvalid = function(action) - -- TODO implement this - return true - end, - }, - [Bot.ACTIONS.REARRANGE_JOKERS] = { - num_args = 2, - func = "rearrange_jokers", - isvalid = function(action) - if G and G.jokers and G.jokers.cards then - if not action[2] then - return true - end - - if - Utils.isTableUnique(action[2]) - and Utils.isTableInRange(action[2], 1, #G.jokers.cards) - and #action[2] == #G.jokers.cards - then - return true - end - end - return false - end, - }, - [Bot.ACTIONS.REARRANGE_CONSUMABLES] = { - num_args = 2, - func = "rearrange_consumables", - isvalid = function(action) - if G and G.consumeables and G.consumeables.cards then - if not action[2] then - return true - end - - if - Utils.isTableUnique(action[2]) - and Utils.isTableInRange(action[2], 1, #G.consumeables.cards) - and #action[2] == #G.consumeables.cards - then - return true - end - end - return false - end, - }, - [Bot.ACTIONS.REARRANGE_HAND] = { - num_args = 2, - func = "rearrange_hand", - isvalid = function(action) - if G and G.hand and G.hand.cards then - if not action[2] then - return true - end - - if - Utils.isTableUnique(action[2]) - and Utils.isTableInRange(action[2], 1, #G.hand.cards) - and #action[2] == #G.hand.cards - then - return true - end - end - return false - end, - }, - [Bot.ACTIONS.PASS] = { - num_args = 1, - func = "", - isvalid = function(action) - return true - end, - }, - [Bot.ACTIONS.START_RUN] = { - num_args = 5, - func = "start_run", - isvalid = function(action) - if G and G.STATE == G.STATES.MENU then - return true - end - return false - end, - }, -} - -return Bot diff --git a/src/lua/hook.lua b/src/lua/hook.lua deleted file mode 100644 index 5da6325..0000000 --- a/src/lua/hook.lua +++ /dev/null @@ -1,152 +0,0 @@ -local unpack = unpack or table.unpack - -Hook = {} - -Hook.FUNCTYPES = { - BREAKPOINT = "__breakpoints", - CALLBACK = "__callbacks", - ONWRITE = "__onwrites", - ONREAD = "__onreads", -} - -local function _callfuncs(obj, which, ...) - local _result = { ... } - - for i = 1, #obj[which], 1 do - _result = { obj[which][i](unpack(_result)) } - end - - if _result ~= nil and #_result > 0 then - return unpack(_result) - end -end - -local function _inithook(obj) - local typ = type(obj) - - -- Return if already initialized - if typ == "table" and obj.__inithook then - return obj - end - - local hook = {} - hook.__inithook = true - hook.__breakpoints = {} - hook.__callbacks = {} - hook.__onreads = {} - hook.__onwrites = {} - hook.__orig = obj - - local _metatable = {} - - if typ == "function" then - _metatable["__call"] = function(obj, ...) - -- Call the breakpoints with original arguments - local _r1 = { _callfuncs(hook, Hook.FUNCTYPES.BREAKPOINT, ...) } - - -- Call the original function with arguments modified by breakpoints OR - -- with the original arguments if no modifications were made (no returns) - local _r2 = (_r1 and #_r1 > 0 and { hook.__orig(unpack(_r1)) }) or { hook.__orig(...) } - - -- Call the callbacks with the return value of the original function OR - -- with the original arguments if original function returned null - local _r3 = (_r2 and #_r2 > 0 and { _callfuncs(hook, Hook.FUNCTYPES.CALLBACK, unpack(_r2)) }) - or { _callfuncs(hook, Hook.FUNCTYPES.CALLBACK, ...) } - - -- The final return value is the return value of the callbacks OR - -- the return value of the original function if null - local _result = (_r3 ~= nil and #_r3 > 0 and _r3) or _r2 - return unpack(_result) - end - end - - if typ == "table" then - _metatable["__index"] = function(...) - local _t, _k = ... - -- Optionally return a new key to read from - local _r = _callfuncs(hook, Hook.FUNCTYPES.ONREAD, ...) - return (_r ~= nil and hook.__orig[_r]) or hook.__orig[_k] - end - - _metatable["__newindex"] = function(...) - local _t, _k, _v = ... - -- Optionally return a new key and value to write - local _r = { _callfuncs(hook, Hook.FUNCTYPES.ONWRITE, ...) } - local _k1, _v1 = nil, nil - if _r ~= nil and #_r > 0 then - _k1, _v1 = unpack(_r) - _k = _k1 or _k - _v = _v1 or _v - end - hook.__orig[_k] = _v - end - end - - setmetatable(hook, _metatable) - - return hook -end - -local function _addfunc(obj, which, func, ephemeral) - if func == nil then - return obj - end - obj = _inithook(obj) - - local _f_index = #obj[which] + 1 - - obj[which][_f_index] = ephemeral - and function(...) - local _ret = func(...) - if _ret == nil or _ret == true then - obj = _clearfunc(obj, which, _f_index) - end - return _ret - end - or func - - return obj -end - -function _clearfunc(obj, which, func_index) - if obj == nil then - return obj - end - obj[which][func_index] = nil - return obj -end - -function Hook.ishooked(obj) - if type(obj) == "table" and obj.__inithook then - return true - end - return false -end - -function Hook.addbreakpoint(obj, func, ephemeral) - return _addfunc(obj, Hook.FUNCTYPES.BREAKPOINT, func, ephemeral) -end - -function Hook.addcallback(obj, func, ephemeral) - return _addfunc(obj, Hook.FUNCTYPES.CALLBACK, func, ephemeral) -end - -function Hook.addonread(obj, func, ephemeral) - return _addfunc(obj, Hook.FUNCTYPES.ONREAD, func, ephemeral) -end - -function Hook.addonwrite(obj, func, ephemeral) - return _addfunc(obj, Hook.FUNCTYPES.ONWRITE, func, ephemeral) -end - -function Hook.clear(obj) - if Hook.ishooked(obj) then - for i = 1, #Hook.FUNCTYPES, 1 do - obj[Hook.FUNCTYPES[i]] = {} - end - end - - return obj.__orig -end - -return Hook diff --git a/src/lua/list.lua b/src/lua/list.lua deleted file mode 100644 index 344eb2c..0000000 --- a/src/lua/list.lua +++ /dev/null @@ -1,44 +0,0 @@ -List = {} - -function List.new() - return { first = 0, last = -1 } -end - -function List.pushleft(list, value) - local first = list.first - 1 - list.first = first - list[first] = value -end - -function List.pushright(list, value) - local last = list.last + 1 - list.last = last - list[last] = value -end - -function List.popleft(list) - local first = list.first - if first > list.last then - error("list is empty") - end - local value = list[first] - list[first] = nil -- to allow garbage collection - list.first = first + 1 - return value -end - -function List.popright(list) - local last = list.last - if list.first > last then - error("list is empty") - end - local value = list[last] - list[last] = nil -- to allow garbage collection - list.last = last - 1 - return value -end - -function List.isempty(list) - return list.first > list.last -end - diff --git a/src/lua/middleware.lua b/src/lua/middleware.lua deleted file mode 100644 index 149e5d4..0000000 --- a/src/lua/middleware.lua +++ /dev/null @@ -1,638 +0,0 @@ -Middleware = {} -Middleware.choosingboostercards = false - -Middleware.queuedactions = List.new() -Middleware.currentaction = nil -Middleware.conditionalactions = {} - -Middleware.BUTTONS = { - - -- Shop Phase Buttons - NEXT_ROUND = nil, - REROLL = nil, - - -- Pack Phase Buttons - SKIP_PACK = nil, -} - -function random_key(tb) - local keys = {} - for k in pairs(tb) do - table.insert(keys, k) - end - return keys[math.random(#keys)] -end - -function random_element(tb) - local keys = {} - for k in pairs(tb) do - table.insert(keys, k) - end - return tb[keys[math.random(#keys)]] -end - -function Middleware.add_event_sequence(events) - local _lastevent = nil - local _totaldelay = 0.0 - - for k, event in pairs(events) do - _totaldelay = _totaldelay + event.delay - - local _event = Event({ - trigger = "after", - delay = _totaldelay, - blocking = false, - func = function() - event.func(event.args) - return true - end, - }) - G.E_MANAGER:add_event(_event) - _lastevent = _event - end - - return _lastevent -end - -local function firewhenready(condition, func) - for i = 1, #Middleware.conditionalactions do - if Middleware.conditionalactions[i] == nil then - Middleware.conditionalactions[i] = { - ready = condition, - fire = func, - } - return nil - end - end - - Middleware.conditionalactions[#Middleware.conditionalactions + 1] = { - ready = condition, - fire = func, - } -end - -local function queueaction(func, delay) - List.pushleft(Middleware.queuedactions, { func = func, delay = delay or 0 }) -end - -local function pushbutton(button, delay) - queueaction(function() - if button and button.config and button.config.button then - G.FUNCS[button.config.button](button) - end - end, delay) -end - -local function pushbutton_instant(button, delay) - if button and button.config and button.config.button then - G.FUNCS[button.config.button](button) - end -end - -local function clickcard(card, delay) - queueaction(function() - --if card and card.click then - card:click() - --end - end, delay) -end - -local function usecard(card, delay) - queueaction(function() - local _use_button = card.children.use_button and card.children.use_button.definition - if _use_button and _use_button.config.button == nil then - local _node_index = card.ability.consumeable and 2 or 1 - _use_button = _use_button.nodes[_node_index] - - if card.area and card.area.config.type == "joker" then - _use_button = card.children.use_button.definition.nodes[1].nodes[1].nodes[1].nodes[1] - pushbutton_instant(_use_button, delay) - else - pushbutton_instant(_use_button, delay) - end - - return - end - local _buy_and_use_button = card.children.buy_and_use_button and card.children.buy_and_use_button.definition - local _buy_button = card.children.buy_button and card.children.buy_button.definition - - if _buy_and_use_button then - pushbutton_instant(_buy_and_use_button, delay) - elseif _buy_button then - pushbutton_instant(_buy_button, delay) - end - end, delay) -end - -local function c_update() - -- Process the queue of Bot events, max 1 per frame - _events = {} - if - not List.isempty(Middleware.queuedactions) - and (not Middleware.currentaction or (Middleware.currentaction and Middleware.currentaction.complete)) - then - local _func_and_delay = List.popright(Middleware.queuedactions) - Middleware.currentaction = - Middleware.add_event_sequence({ { func = _func_and_delay.func, delay = _func_and_delay.delay } }) - end - - -- Run functions that have been waiting for a condition to be met - for i = 1, #Middleware.conditionalactions do - if Middleware.conditionalactions[i] then - local _result = { Middleware.conditionalactions[i].ready() } - local _ready = table.remove(_result, 1) - if _ready == true then - Middleware.conditionalactions[i].fire(unpack(_result)) - Middleware.conditionalactions[i] = nil - end - end - end -end - -function Middleware.c_play_hand() - firewhenready(function() - -- Check if there's an action in the queue for select_cards_from_hand - if not List.isempty(BalatrobotAPI.q_select_cards_from_hand) then - local _action_data = List.popright(BalatrobotAPI.q_select_cards_from_hand) - local _action = _action_data[2][1] - local _cards_to_play = _action_data[2][2] - return true, _action, _cards_to_play - else - return false - end - end, function(_action, _cards_to_play) - for i = 1, #_cards_to_play do - clickcard(G.hand.cards[_cards_to_play[i]]) - end - - -- Option 1: Play Hand - if _action == Bot.ACTIONS.PLAY_HAND then - local _play_button = UIBox:get_UIE_by_ID("play_button", G.buttons.UIRoot) - pushbutton(_play_button) - end - - -- Option 2: Discard Hand - if _action == Bot.ACTIONS.DISCARD_HAND then - local _discard_button = UIBox:get_UIE_by_ID("discard_button", G.buttons.UIRoot) - pushbutton(_discard_button) - end - end) -end - -function Middleware.c_select_blind() - if not G or not G.GAME or not G.GAME.blind_on_deck then - return - end - - local _blind_on_deck = G.GAME.blind_on_deck - - firewhenready(function() - if - G.GAME - and G.GAME.blind_on_deck - and (G.GAME.blind_on_deck == "Small" or G.GAME.blind_on_deck == "Big" or G.GAME.blind_on_deck == "Boss") - then - -- Check if there's an action in the queue for skip_or_select_blind - if not List.isempty(BalatrobotAPI.q_skip_or_select_blind) then - local _action_data = List.popright(BalatrobotAPI.q_skip_or_select_blind) - local _action = _action_data[2][1] - return true, _action - else - return false - end - end - return false - end, function(_action) - local _blind_obj = G.blind_select_opts[string.lower(_blind_on_deck)] - - local _button = nil - if _action == Bot.ACTIONS.SELECT_BLIND then - local _select_button = _blind_obj:get_UIE_by_ID("select_blind_button") - _button = _select_button - elseif _action == Bot.ACTIONS.SKIP_BLIND then - local _skip_button = _blind_obj:get_UIE_by_ID("tag_" .. _blind_on_deck).children[2] - _button = _skip_button - end - - pushbutton(_button) - end) -end - -function Middleware.c_choose_booster_cards() - if Middleware.choosingboostercards == true then - return - end - if not G.pack_cards.cards then - return - end - - Middleware.choosingboostercards = true - - firewhenready(function() - -- Check if there's an action in the queue for select_booster_action - if not List.isempty(BalatrobotAPI.q_select_booster_action) then - local _action_data = List.popright(BalatrobotAPI.q_select_booster_action) - local _action = _action_data[2][1] - local _card = _action_data[2][2] - local _hand_cards = _action_data[2][3] - return true, _action, _card, _hand_cards - else - return false - end - end, function(_action, _card, _hand_cards) - if _action == Bot.ACTIONS.SKIP_BOOSTER_PACK then - pushbutton(Middleware.BUTTONS.SKIP_PACK) - elseif _action == Bot.ACTIONS.SELECT_BOOSTER_CARD then - -- Click each card from your deck first (only occurs if _pack_card is consumable) - if _hand_cards then - for i = 1, #_hand_cards do - clickcard(G.hand.cards[_hand_cards[i]]) - end - end - - -- Then select the booster card to activate - if _card then - clickcard(G.pack_cards.cards[_card[1]]) - usecard(G.pack_cards.cards[_card[1]]) - end - end - - if G.GAME.pack_choices - 1 > 0 then - queueaction(function() - firewhenready(function() - return Middleware.BUTTONS.SKIP_PACK ~= nil - and Middleware.BUTTONS.SKIP_PACK.config.button == "skip_booster" - and Middleware.choosingboostercards == false - and G - and G.pack_cards - and G.pack_cards.cards - end, function() - Middleware.c_choose_booster_cards() - end) - end, 0.0) - else - if G.GAME.PACK_INTERRUPT == G.STATES.BLIND_SELECT then - queueaction(function() - firewhenready(function() - return G.STATE_COMPLETE and G.STATE == G.STATES.BLIND_SELECT - end, function() - Middleware.choosingboostercards = false - Middleware.c_select_blind() - end) - end, 0.0) - end - end - end) -end - -function Middleware.c_shop() - local _done_shopping = false - - local _b_round_end_shop = true - local _b_reroll_shop = Middleware.BUTTONS.REROLL - and Middleware.BUTTONS.REROLL.config - and Middleware.BUTTONS.REROLL.config.button - - local _cards_to_buy = {} - for i = 1, #G.shop_jokers.cards do - _cards_to_buy[i] = G.shop_jokers.cards[i].cost <= G.GAME.dollars and G.shop_jokers.cards[i] or nil - end - - local _vouchers_to_buy = {} - for i = 1, #G.shop_vouchers.cards do - _vouchers_to_buy[i] = G.shop_vouchers.cards[i].cost <= G.GAME.dollars and G.shop_vouchers.cards[i] or nil - end - - local _boosters_to_buy = {} - for i = 1, #G.shop_booster.cards do - _boosters_to_buy[i] = G.shop_booster.cards[i].cost <= G.GAME.dollars and G.shop_booster.cards[i] or nil - end - - local _choices = {} - _choices[Bot.ACTIONS.END_SHOP] = _b_round_end_shop - _choices[Bot.ACTIONS.REROLL_SHOP] = _b_reroll_shop - _choices[Bot.ACTIONS.BUY_CARD] = #_cards_to_buy > 0 and _cards_to_buy or nil - _choices[Bot.ACTIONS.BUY_VOUCHER] = #_vouchers_to_buy > 0 and _vouchers_to_buy or nil - _choices[Bot.ACTIONS.BUY_BOOSTER] = #_boosters_to_buy > 0 and _boosters_to_buy or nil - - firewhenready(function() - -- Check if there's an action in the queue for select_shop_action - if not List.isempty(BalatrobotAPI.q_select_shop_action) then - local _action_data = List.popright(BalatrobotAPI.q_select_shop_action) - local _action = _action_data[2][1] - local _card = _action_data[2][2] - return true, _action, _card - else - return false - end - end, function(_action, _card) - if _action == Bot.ACTIONS.END_SHOP then - pushbutton(Middleware.BUTTONS.NEXT_ROUND) - _done_shopping = true - elseif _action == Bot.ACTIONS.REROLL_SHOP then - pushbutton(Middleware.BUTTONS.REROLL) - elseif _action == Bot.ACTIONS.BUY_CARD then - clickcard(_choices[Bot.ACTIONS.BUY_CARD][_card[1]]) - usecard(_choices[Bot.ACTIONS.BUY_CARD][_card[1]]) - elseif _action == Bot.ACTIONS.BUY_VOUCHER then - clickcard(_choices[Bot.ACTIONS.BUY_VOUCHER][_card[1]]) - usecard(_choices[Bot.ACTIONS.BUY_VOUCHER][_card[1]]) - elseif _action == Bot.ACTIONS.BUY_BOOSTER then - _done_shopping = true - clickcard(_choices[Bot.ACTIONS.BUY_BOOSTER][_card[1]]) - usecard(_choices[Bot.ACTIONS.BUY_BOOSTER][_card[1]]) - end - - if not _done_shopping then - queueaction(function() - firewhenready(function() - return G.shop ~= nil and G.STATE_COMPLETE and G.STATE == G.STATES.SHOP - end, Middleware.c_shop) - end) - end - end) -end - -function Middleware.c_rearrange_hand() - firewhenready(function() - -- Check if there's an action in the queue for rearrange_hand - if not List.isempty(BalatrobotAPI.q_rearrange_hand) then - local _action_data = List.popright(BalatrobotAPI.q_rearrange_hand) - local _action = _action_data[2][1] - local _order = _action_data[2][2] - return true, _action, _order - else - return false - end - end, function(_action, _order) - Middleware.c_play_hand() - - if not _order or #_order ~= #G.hand.cards then - return - end - - queueaction(function() - for k, v in ipairs(_order) do - if k < v then - G.hand.cards[k], G.hand.cards[v] = G.hand.cards[v], G.hand.cards[k] - end - end - - G.hand:set_ranks() - end) - end) -end - -function Middleware.c_rearrange_consumables() - firewhenready(function() - -- Check if there's an action in the queue for rearrange_consumables - if not List.isempty(BalatrobotAPI.q_rearrange_consumables) then - local _action_data = List.popright(BalatrobotAPI.q_rearrange_consumables) - local _action = _action_data[2][1] - local _order = _action_data[2][2] - return true, _action, _order - else - return false - end - end, function(_action, _order) - Middleware.c_rearrange_hand() - - if not _order or #_order ~= #G.consumables.cards then - return - end - - queueaction(function() - for k, v in ipairs(_order) do - if k < v then - G.consumeables.cards[k], G.consumeables.cards[v] = G.consumeables.cards[v], G.consumeables.cards[k] - end - end - - G.consumeables:set_ranks() - end) - end) -end - -function Middleware.c_use_or_sell_consumables() - firewhenready(function() - -- Check if there's an action in the queue for use_or_sell_consumables - if not List.isempty(BalatrobotAPI.q_use_or_sell_consumables) then - local _action_data = List.popright(BalatrobotAPI.q_use_or_sell_consumables) - local _action = _action_data[2][1] - local _cards = _action_data[2][2] - return true, _action, _cards - else - return false - end - end, function(_action, _cards) - Middleware.c_rearrange_consumables() - - if _cards then - -- TODO implement this - end - end) -end - -function Middleware.c_rearrange_jokers() - firewhenready(function() - -- Check if there's an action in the queue for rearrange_jokers - if not List.isempty(BalatrobotAPI.q_rearrange_jokers) then - local _action_data = List.popright(BalatrobotAPI.q_rearrange_jokers) - local _action = _action_data[2][1] - local _order = _action_data[2][2] - return true, _action, _order - else - return false - end - end, function(_action, _order) - Middleware.c_use_or_sell_consumables() - - if not _order or #_order ~= #G.jokers.cards then - return - end - - queueaction(function() - for k, v in ipairs(_order) do - if k < v then - G.jokers.cards[k], G.jokers.cards[v] = G.jokers.cards[v], G.jokers.cards[k] - end - end - - G.jokers:set_ranks() - end) - end) -end - -function Middleware.c_sell_jokers() - firewhenready(function() - -- Check if there's an action in the queue for sell_jokers - if not List.isempty(BalatrobotAPI.q_sell_jokers) then - local _action_data = List.popright(BalatrobotAPI.q_sell_jokers) - local _action = _action_data[2][1] - local _cards = _action_data[2][2] - return true, _action, _cards - else - return false - end - end, function(_action, _cards) - Middleware.c_rearrange_jokers() - - if _action == Bot.ACTIONS.SELL_JOKER and _cards then - for i = 1, #_cards do - clickcard(G.jokers.cards[_cards[i]]) - usecard(G.jokers.cards[_cards[i]]) - end - end - end) -end - -function Middleware.c_start_run() - firewhenready(function() - -- Check if there's an action in the queue for start_run - if not List.isempty(BalatrobotAPI.q_start_run) then - local _action_data = List.popright(BalatrobotAPI.q_start_run) - local _action = _action_data[2][1] - local _stake = _action_data[2][2] - local _deck = _action_data[2][3] - local _seed = _action_data[2][4] - local _challenge = _action_data[2][5] - _stake = _stake ~= nil and tonumber(_stake[1]) or 1 - _deck = _deck ~= nil and _deck[1] or "Red Deck" - _seed = _seed ~= nil and _seed[1] or nil - _challenge = _challenge ~= nil and _challenge[1] or nil - return true, _action, _stake, _deck, _seed, _challenge - else - return false - end - end, function(_action, _stake, _deck, _seed, _challenge) - queueaction(function() - local _play_button = G.MAIN_MENU_UI:get_UIE_by_ID("main_menu_play") - G.FUNCS[_play_button.config.button]({ - config = {}, - }) - G.FUNCS.exit_overlay_menu() - end) - - queueaction(function() - for k, v in pairs(G.P_CENTER_POOLS.Back) do - if v.name == _deck then - G.GAME.selected_back:change_to(v) - G.GAME.viewed_back:change_to(v) - end - end - - for i = 1, #G.CHALLENGES do - if G.CHALLENGES[i].name == _challenge then - _challenge = G.CHALLENGES[i] - end - end - G.FUNCS.start_run(nil, { stake = _stake, seed = _seed, challenge = _challenge }) - end, 1.0) - end) -end - -local function w_gamestate(...) - local _t, _k, _v = ... - - -- If we lose a run, we want to go back to the main menu - -- Before we try to start a new run - if _k == "STATE" and _v and G and G.STATES and _v == G.STATES.GAME_OVER then - if G.FUNCS and G.FUNCS.go_to_menu then - G.FUNCS.go_to_menu({}) - end - end - - if _k == "STATE" and _v and G and G.STATES and _v == G.STATES.MENU then - Middleware.c_start_run() - end -end - -local function c_initgamehooks() - -- Hooks break SAVE_MANAGER.channel:push so disable saving. Who needs it when you are botting anyway... - if G and G.SAVE_MANAGER then - G.SAVE_MANAGER = { - channel = { - push = function() end, - }, - } - end - - -- Detect when hand has been drawn - if G and G.GAME and G.GAME.blind and G.GAME.blind.drawn_to_hand then - G.GAME.blind.drawn_to_hand = Hook.addcallback(G.GAME.blind.drawn_to_hand, function(...) - firewhenready(function() - return G.buttons and G.STATE_COMPLETE and G.STATE == G.STATES.SELECTING_HAND - end, function() - Middleware.c_sell_jokers() - end) - end) - end - - -- Hook button snaps - G.CONTROLLER.snap_to = Hook.addcallback(G.CONTROLLER.snap_to, function(...) - local _self = ... - - if - _self - and _self.snap_cursor_to.node - and _self.snap_cursor_to.node.config - and _self.snap_cursor_to.node.config.button - then - local _button = _self.snap_cursor_to.node - local _buttonfunc = _self.snap_cursor_to.node.config.button - - if _buttonfunc == "select_blind" and G.STATE == G.STATES.BLIND_SELECT then - Middleware.c_select_blind() - elseif _buttonfunc == "cash_out" then - pushbutton(_button) - elseif _buttonfunc == "toggle_shop" and G.shop ~= nil then -- 'next_round_button' - Middleware.BUTTONS.NEXT_ROUND = _button - - firewhenready(function() - return G.shop ~= nil and G.STATE_COMPLETE and G.STATE == G.STATES.SHOP - end, Middleware.c_shop) - end - end - end) - - -- Set reroll availability - G.FUNCS.can_reroll = Hook.addcallback(G.FUNCS.can_reroll, function(...) - local _e = ... - Middleware.BUTTONS.REROLL = _e - end) - - -- Booster pack skip availability - G.FUNCS.can_skip_booster = Hook.addcallback(G.FUNCS.can_skip_booster, function(...) - local _e = ... - Middleware.BUTTONS.SKIP_PACK = _e - if - Middleware.BUTTONS.SKIP_PACK ~= nil - and Middleware.BUTTONS.SKIP_PACK.config.button == "skip_booster" - and Middleware.choosingboostercards == false - and G - and G.pack_cards - and G.pack_cards.cards - then - Middleware.c_choose_booster_cards() - end - end) -end - -function Middleware.hookbalatro() - -- Unlock all card backs - for k, v in pairs(G.P_CENTERS) do - if not v.demo and not v.wip and v.set == "Back" then - v.alerted = true - v.discovered = true - v.unlocked = true - end - end - - -- Start game from main menu - G.start_run = Hook.addcallback(G.start_run, c_initgamehooks) - G = Hook.addonwrite(G, w_gamestate) - G.update = Hook.addcallback(G.update, c_update) -end - -return Middleware - diff --git a/src/lua/utils.lua b/src/lua/utils.lua index 4d64b71..a67c384 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -1,247 +1,98 @@ - -Utils = { } - -function Utils.getCardData(card) - local _card = { } - - _card.label = card.label - _card.name = card.config.card.name - _card.suit = card.config.card.suit - _card.value = card.config.card.value - _card.card_key = card.config.card_key - - return _card -end - -function Utils.getDeckData() - local _deck = { } - - return _deck -end - -function Utils.getHandData() - local _hand = { } - - if G and G.hand and G.hand.cards then - for i = 1, #G.hand.cards do - local _card = Utils.getCardData(G.hand.cards[i]) - _hand[i] = _card - end - end - - return _hand -end - -function Utils.getJokersData() - local _jokers = { } - - if G and G.jokers and G.jokers.cards then - for i = 1, #G.jokers.cards do - local _card = Utils.getCardData(G.jokers.cards[i]) - _jokers[i] = _card - end - end - - return _jokers -end - -function Utils.getConsumablesData() - local _consumables = { } - - if G and G.consumables and G.consumables.cards then - for i = 1, #G.consumables.cards do - local _card = Utils.getCardData(G.consumables.cards[i]) - _consumables[i] = _card - end - end - - return _consumables -end - -function Utils.getBlindData() - local _blinds = { } - - if G and G.GAME and G.GAME.blind_on_deck then - _blinds.ondeck = G.GAME.blind_on_deck - end - - return _blinds -end - -function Utils.getAnteData() - local _ante = { } - _ante.blinds = Utils.getBlindData() - - return _ante -end - -function Utils.getBackData() - local _back = { } - - return _back -end - -function Utils.getShopData() - local _shop = { } - if not G or not G.shop then return _shop end - - if G.GAME and G.GAME.current_round then - _shop.reroll_cost = G.GAME.current_round.reroll_cost - end - _shop.cards = { } - _shop.boosters = { } - _shop.vouchers = { } - - for i = 1, #G.shop_jokers.cards do - _shop.cards[i] = Utils.getCardData(G.shop_jokers.cards[i]) - end - - for i = 1, #G.shop_booster.cards do - _shop.boosters[i] = Utils.getCardData(G.shop_booster.cards[i]) - end - - for i = 1, #G.shop_vouchers.cards do - _shop.vouchers[i] = Utils.getCardData(G.shop_vouchers.cards[i]) - end - - return _shop -end - -function Utils.getHandScoreData() - local _handscores = { } - - return _handscores -end - -function Utils.getTagsData() - local _tags = { } - - return _tags -end - -function Utils.getRoundData() - local _current_round = { } - - if G and G.GAME and G.GAME.current_round then - _current_round.discards_left = G.GAME.current_round.discards_left - end - - return _current_round -end - -function Utils.getGameData() - local _game = { } - - if G and G.STATE and G.GAME then - _game.state = G.STATE - _game.num_hands_played = G.GAME.hands_played - _game.num_skips = G.GAME.Skips - _game.round = G.GAME.round - _game.discount_percent = G.GAME.discount_percent - _game.interest_cap = G.GAME.interest_cap - _game.inflation = G.GAME.inflation - _game.dollars = G.GAME.dollars - _game.max_jokers = G.GAME.max_jokers - _game.bankrupt_at = G.GAME.bankrupt_at - _game.chips = G.GAME.chips - end - - return _game -end - -function Utils.getGamestate() - -- TODO - local _gamestate = { } - - _gamestate = Utils.getGameData() - - _gamestate.deckback = Utils.getBackData() - _gamestate.deck = Utils.getDeckData() -- Ensure this is not ordered - _gamestate.hand = Utils.getHandData() - _gamestate.jokers = Utils.getJokersData() - _gamestate.consumables = Utils.getConsumablesData() - _gamestate.ante = Utils.getAnteData() - _gamestate.shop = Utils.getShopData() -- Empty if not in shop phase - _gamestate.handscores = Utils.getHandScoreData() - _gamestate.tags = Utils.getTagsData() - _gamestate.current_round = Utils.getRoundData() - - return _gamestate -end - -function Utils.parseaction(data) - -- Protocol is ACTION|arg1|arg2 - action = data:match("^([%a%u_]*)") - params = data:match("|(.*)") - - if action then - local _action = Bot.ACTIONS[action] - - if not _action then - return nil - end - - local _actiontable = { } - _actiontable[1] = _action - - if params then - local _i = 2 - for _arg in params:gmatch("[%w%s,]+") do - local _splitstring = { } - local _j = 1 - for _str in _arg:gmatch('([^,]+)') do - _splitstring[_j] = tonumber(_str) or _str - _j = _j + 1 - end - _actiontable[_i] = _splitstring - _i = _i + 1 - end - end - - return _actiontable - end -end - -Utils.ERROR = { - NOERROR = 1, - NUMPARAMS = 2, - MSGFORMAT = 3, - INVALIDACTION = 4, -} - -function Utils.validateAction(action) - if action and #action > 1 and #action > Bot.ACTIONPARAMS[action[1]].num_args then - return Utils.ERROR.NUMPARAMS - elseif not action then - return Utils.ERROR.MSGFORMAT - else - if not Bot.ACTIONPARAMS[action[1]].isvalid(action) then - return Utils.ERROR.INVALIDACTION - end - end - - return Utils.ERROR.NOERROR -end - -function Utils.isTableUnique(table) - if table == nil then return true end - - local _seen = { } - for i = 1, #table do - if _seen[table[i]] then return false end - _seen[table[i]] = table[i] - end - - return true -end - -function Utils.isTableInRange(table, min, max) - if table == nil then return true end - - for i = 1, #table do - if table[i] < min or table[i] > max then return false end - end - return true -end - -return Utils \ No newline at end of file +utils = {} +local json = require("json") + +function utils.get_game_state() + local game = nil + if G.GAME then + game = { + hands_played = G.GAME.hands_played, + skips = G.GAME.Skips, + round = G.GAME.round, + discount_percent = G.GAME.discount_percent, + interest_cap = G.GAME.interest_cap, + inflation = G.GAME.inflation, + dollars = G.GAME.dollars, + max_jokers = G.GAME.max_jokers, + bankrupt_at = G.GAME.bankrupt_at, + chips = G.GAME.chips, + current_round = { + discards_left = G.GAME.current_round.discards_left, + }, + } + end + + local hand = nil + if G.hand then + hand = {} + for i, card in pairs(G.hand.cards) do + hand[i] = { + label = card.label, + config = { + card = { + name = card.config.card.name, + suit = card.config.card.suit, + value = card.config.card.value, + card_key = card.config.card_key, + }, + }, + } + end + end + + local jokers = {} + if G.jokers and G.jokers.cards then + for i, card in pairs(G.jokers.cards) do + jokers[i] = { + label = card.label, + config = { + center = card.config.center, + }, + } + end + end + + -- TODO: add consumables, ante, and shop + + return { + state = G.STATE, + game = game, + hand = hand, + jokers = jokers, + } +end + +function utils.table_to_json(obj, depth) + depth = depth or 3 + + local function sanitize_for_json(value, current_depth) + if current_depth <= 0 then + return "..." + end + + local value_type = type(value) + + if value_type == "nil" then + return nil + elseif value_type == "string" or value_type == "number" or value_type == "boolean" then + return value + elseif value_type == "function" then + return "function" + elseif value_type == "userdata" then + return "userdata" + elseif value_type == "table" then + local sanitized = {} + for k, v in pairs(value) do + local key = type(k) == "string" and k or tostring(k) + sanitized[key] = sanitize_for_json(v, current_depth - 1) + end + return sanitized + else + return tostring(value) + end + end + + local sanitized = sanitize_for_json(obj, depth) + return json.encode(sanitized) +end + +return utils From fb073e0f16c40dad6fd3f1ed1aa4e55b3e0452a8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 18:07:33 +0200 Subject: [PATCH 05/60] feat: update mod entry point to new lua code --- balatrobot.lua | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 6c44de0..595e786 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -24,19 +24,27 @@ BALATRO_BOT_CONFIG = { frame_ratio = 1, } -assert(SMODS.load_file("src/lua/list.lua"))() -assert(SMODS.load_file("src/lua/hook.lua"))() +-- Load debug +local success, dpAPI = pcall(require, "debugplus-api") + +-- Load minimal required files assert(SMODS.load_file("src/lua/utils.lua"))() -assert(SMODS.load_file("src/lua/bot.lua"))() -assert(SMODS.load_file("src/lua/middleware.lua"))() assert(SMODS.load_file("src/lua/api.lua"))() --- Init middleware -Middleware.hookbalatro() -sendDebugMessage("Middleware loaded", "BALATROBOT") +if success and dpAPI.isVersionCompatible(1) then + local debugplus = dpAPI.registerID("balatrobot") + debugplus.addCommand({ + name = "env", + shortDesc = "Get game state", + desc = "Get the current game state, useful for debugging", + exec = function(args, rawArgs, dp) + debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G, 2) .. "}") + end, + }) +end --- Init API (includes queue initialization) -BalatrobotAPI.init() +-- Initialize API +API.init() sendDebugMessage("API loaded", "BALATROBOT") sendInfoMessage("BalatroBot loaded - version " .. SMODS.current_mod.version, "BALATROBOT") From a11059cf03e6a42649f4dd5a1a465965594a1dc2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 18:08:34 +0200 Subject: [PATCH 06/60] chore: add old implementation to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 324c328..198124d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ gamestate_cache .envrc .luarc.json *.log +src/lua_old +balatrobot_old.lua From c9cbc9e959df14b85f4488f8302ebec3fcf70eee Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 22:05:21 +0200 Subject: [PATCH 07/60] feat: implement game speed up --- balatrobot.lua | 27 ++++++++++++--------------- src/lua/api.lua | 14 ++++++++++---- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 595e786..5a059a4 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -2,26 +2,24 @@ ---Main entry point for the BalatroBot mod ---@class BalatrobotConfig ----@field enabled boolean Disables ALL mod functionality if false ---@field port string Port for the bot to listen on ----@field dt number Tells the game that every update is dt seconds long ----@field uncap_fps boolean Whether to uncap the frame rate ----@field instant_move boolean Whether to enable instant card movement ----@field disable_vsync boolean Whether to disable vertical sync ----@field disable_card_eval_status_text boolean Whether to disable card evaluation status text (e.g. +10 when scoring a queen) ----@field frame_ratio integer Draw every nth frame, set to 1 for normal rendering +---@field dt number Tells the game that every update is dt seconds long. +---@field max_fps integer Maximum frames per second +---@field vsync_enabled boolean Whether vertical sync is enabled ---Global configuration for the BalatroBot mod ---@type BalatrobotConfig BALATRO_BOT_CONFIG = { - enabled = true, port = "12346", - dt = 1.0 / 60.0, - uncap_fps = false, - instant_move = false, - disable_vsync = false, - disable_card_eval_status_text = true, - frame_ratio = 1, + dt = 8.0 / 60.0, + max_fps = 60, + vsync_enabled = false, + + -- -- Default values for the original game + -- port = "12346", + -- dt = 1.0 / 60.0, + -- vsync_enabled = true, + -- max_fps = nil, } -- Load debug @@ -45,6 +43,5 @@ end -- Initialize API API.init() -sendDebugMessage("API loaded", "BALATROBOT") sendInfoMessage("BalatroBot loaded - version " .. SMODS.current_mod.version, "BALATROBOT") diff --git a/src/lua/api.lua b/src/lua/api.lua index a9494f3..930c07d 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -75,13 +75,19 @@ function API.send_response(response) end function API.init() - -- API.setup_game_hooks() - -- Hook into the game's update loop local original_update = love.update love.update = function(dt) - original_update(dt) - API.update(dt) + original_update(BALATRO_BOT_CONFIG.dt) + API.update(BALATRO_BOT_CONFIG.dt) + end + + if not BALATRO_BOT_CONFIG.vsync_enabled then + love.window.setVSync(0) + end + + if BALATRO_BOT_CONFIG.max_fps then + G.FPS_CAP = 60 end sendInfoMessage("BalatrobotAPI initialized", "BALATROBOT") From e15d9cbc8670d3c27c51ad997ad0aab227a33f62 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 8 Jul 2025 22:08:18 +0200 Subject: [PATCH 08/60] refactor(test): group test in class and setup/teardown methods --- tests/conftest.py | 24 +- tests/test_api_functions.py | 431 +++++++++++++++++------------------- tests/test_connection.py | 2 +- 3 files changed, 216 insertions(+), 241 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2feb44f..be542ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,9 +8,9 @@ # Connection settings HOST = "127.0.0.1" -PORT = 12346 -TIMEOUT = 30.0 -BUFFER_SIZE = 65536 # 64KB buffer for UDP messages +PORT: int = 12346 # default port for BalatroBot UDP API +TIMEOUT: float = 30.0 # timeout for socket operations in seconds +BUFFER_SIZE: int = 65536 # 64KB buffer for UDP messages @pytest.fixture @@ -52,11 +52,19 @@ def receive_api_message(sock: socket.socket) -> dict[str, Any]: return json.loads(data.decode().strip()) -def teardown_test(sock: socket.socket) -> None: - """Teardown helper to return to menu state after test. +def send_and_receive_api_message( + sock: socket.socket, name: str, arguments: dict +) -> dict[str, Any]: + """Send a properly formatted JSON API message and receive the response. Args: - sock: Socket to send teardown message through. + sock: Socket to send through. + name: Function name to call. + arguments: Arguments dictionary for the function. + + Returns: + The game state after the message is sent and received. """ - send_api_message(sock, "go_to_menu", {}) - receive_api_message(sock) + send_api_message(sock, name, arguments) + game_state = receive_api_message(sock) + return game_state diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index cbb0f07..28ef5a2 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -1,266 +1,233 @@ """Tests for BalatroBot UDP API game functions.""" import socket +from typing import Generator import pytest -from conftest import HOST, PORT, receive_api_message, send_api_message, teardown_test +from conftest import send_and_receive_api_message from balatrobot.enums import State -def test_get_game_state_response(udp_client: socket.socket) -> None: - """Test get_game_state message returns valid JSON game state.""" - send_api_message(udp_client, "get_game_state", {}) +class TestGetGameState: + """Tests for the get_game_state API endpoint.""" - game_state = receive_api_message(udp_client) - assert isinstance(game_state, dict) + @pytest.fixture(autouse=True) + def setup_and_teardown( + self, udp_client: socket.socket + ) -> Generator[None, None, None]: + """Set up and tear down each test method.""" + yield + send_and_receive_api_message(udp_client, "go_to_menu", {}) + def test_get_game_state_response(self, udp_client: socket.socket) -> None: + """Test get_game_state message returns valid JSON game state.""" + game_state = send_and_receive_api_message(udp_client, "get_game_state", {}) + assert isinstance(game_state, dict) -def test_game_state_structure(udp_client: socket.socket) -> None: - """Test that game state contains expected top-level fields.""" - send_api_message(udp_client, "get_game_state", {}) + def test_game_state_structure(self, udp_client: socket.socket) -> None: + """Test that game state contains expected top-level fields.""" + game_state = send_and_receive_api_message(udp_client, "get_game_state", {}) - game_state = receive_api_message(udp_client) + assert isinstance(game_state, dict) - assert isinstance(game_state, dict) + expected_keys = {"state", "game"} + assert expected_keys.issubset(game_state.keys()) + assert isinstance(game_state["state"], int) + assert isinstance(game_state["game"], (dict, type(None))) - expected_keys = {"state", "game"} - assert expected_keys.issubset(game_state.keys()) - assert isinstance(game_state["state"], int) - assert isinstance(game_state["game"], (dict, type(None))) + def test_game_state_during_run(self, udp_client: socket.socket) -> None: + """Test getting game state at different points during a run.""" + # Start a run + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "STATE_TEST", + } + initial_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) + assert initial_state["state"] == State.BLIND_SELECT.value + # Get game state again to ensure it's consistent + current_state = send_and_receive_api_message(udp_client, "get_game_state", {}) -def test_start_run(udp_client: socket.socket) -> None: - """Test starting a run and verifying the state.""" - start_run_args = { - "deck": "Red Deck", - "stake": 1, - "challenge": None, - "seed": "EXAMPLE", - } - send_api_message(udp_client, "start_run", start_run_args) - game_state = receive_api_message(udp_client) + assert current_state["state"] == State.BLIND_SELECT.value + assert current_state["state"] == initial_state["state"] - assert game_state["state"] == State.BLIND_SELECT.value - teardown_test(udp_client) +class TestStartRun: + """Tests for the start_run API endpoint.""" + @pytest.fixture(autouse=True) + def setup_and_teardown( + self, udp_client: socket.socket + ) -> Generator[None, None, None]: + """Set up and tear down each test method.""" + yield + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + def test_start_run(self, udp_client: socket.socket) -> None: + """Test starting a run and verifying the state.""" + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "EXAMPLE", + } + game_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) + + assert game_state["state"] == State.BLIND_SELECT.value + + def test_start_run_with_challenge(self, udp_client: socket.socket) -> None: + """Test starting a run with a challenge.""" + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": "The Omelette", + "seed": "CHALLENGE_TEST", + } + game_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) -def test_start_run_with_challenge(udp_client: socket.socket) -> None: - """Test starting a run with a challenge.""" - start_run_args = { - "deck": "Red Deck", - "stake": 1, - "challenge": "The Omelette", - "seed": "CHALLENGE_TEST", - } - send_api_message(udp_client, "start_run", start_run_args) - game_state = receive_api_message(udp_client) + assert game_state["state"] == State.BLIND_SELECT.value + assert len(game_state["jokers"]) == 5 # jokers in The Omelette challenge + + def test_start_run_different_stakes(self, udp_client: socket.socket) -> None: + """Test starting runs with different stake levels.""" + for stake in [1, 2, 3]: + start_run_args = { + "deck": "Red Deck", + "stake": stake, + "challenge": None, + "seed": f"STAKE_{stake}", + } + game_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) + + assert game_state["state"] == State.BLIND_SELECT.value + + # Go back to menu for next iteration + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + def test_start_run_missing_required_args(self, udp_client: socket.socket) -> None: + """Test start_run with missing required arguments.""" + # Missing deck + incomplete_args = { + "stake": 1, + "challenge": None, + "seed": "EXAMPLE", + } + # Should receive error response + response = send_and_receive_api_message( + udp_client, "start_run", incomplete_args + ) + assert isinstance(response, dict) + assert "error" in response + assert "Invalid deck arg" in response["error"] + + def test_start_run_invalid_deck(self, udp_client: socket.socket) -> None: + """Test start_run with invalid deck name.""" + invalid_args = { + "deck": "Nonexistent Deck", + "stake": 1, + "challenge": None, + "seed": "EXAMPLE", + } + # Should receive error response + response = send_and_receive_api_message(udp_client, "start_run", invalid_args) + assert isinstance(response, dict) + assert "error" in response + assert "Invalid deck arg" in response["error"] - assert game_state["state"] == State.BLIND_SELECT.value - assert len(game_state["jokers"]) == 5 # jokers in The Omelette challenge - teardown_test(udp_client) +class TestGoToMenu: + """Tests for the go_to_menu API endpoint.""" + def test_go_to_menu(self, udp_client: socket.socket) -> None: + """Test going to the main menu.""" + game_state = send_and_receive_api_message(udp_client, "go_to_menu", {}) + assert game_state["state"] == State.MENU.value -def test_start_run_different_stakes(udp_client: socket.socket) -> None: - """Test starting runs with different stake levels.""" - for stake in [1, 2, 3]: + def test_go_to_menu_from_run(self, udp_client: socket.socket) -> None: + """Test going to menu from within a run.""" + # First start a run start_run_args = { "deck": "Red Deck", - "stake": stake, + "stake": 1, "challenge": None, - "seed": f"STAKE_{stake}", + "seed": "MENU_TEST", } - send_api_message(udp_client, "start_run", start_run_args) + initial_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) + assert initial_state["state"] == State.BLIND_SELECT.value - game_state = receive_api_message(udp_client) + # Now go to menu + menu_state = send_and_receive_api_message(udp_client, "go_to_menu", {}) - assert game_state["state"] == State.BLIND_SELECT.value + assert menu_state["state"] == State.MENU.value - # Go back to menu - teardown_test(udp_client) - - -def test_select_blind(udp_client: socket.socket) -> None: - """Test selecting a blind during the blind selection phase.""" - # First start a run to get to blind select state - start_run_args = { - "deck": "Red Deck", - "stake": 1, - "challenge": None, - "seed": "SELECT_BLIND", - } - send_api_message(udp_client, "start_run", start_run_args) - - # Wait for the run to start and reach blind select state - game_state = receive_api_message(udp_client) - assert game_state["state"] == State.BLIND_SELECT.value - - # Now select the blind - select_blind_args = {"action": "select"} - send_api_message(udp_client, "skip_or_select_blind", select_blind_args) - # Wait for response after blind selection - game_state = receive_api_message(udp_client) +class TestSkipOrSelectBlind: + """Tests for the skip_or_select_blind API endpoint.""" - # Verify we get a valid game state response - assert game_state["state"] == State.SELECTING_HAND.value - - # Assert that there are 8 cards in the hand - assert len(game_state["hand"]) == 8 - - # Go back to menu - teardown_test(udp_client) - - -def test_skip_blind(udp_client: socket.socket) -> None: - """Test skipping a blind during the blind selection phase.""" - # First start a run to get to blind select state - start_run_args = { - "deck": "Red Deck", - "stake": 1, - "challenge": None, - "seed": "SKIP_BLIND", - } - send_api_message(udp_client, "start_run", start_run_args) - - # Wait for the run to start and reach blind select state - game_state = receive_api_message(udp_client) - assert game_state["state"] == State.BLIND_SELECT.value - - # Now skip the blind - skip_blind_args = {"action": "skip"} - send_api_message(udp_client, "skip_or_select_blind", skip_blind_args) - - # Wait for response after blind skip - game_state = receive_api_message(udp_client) - - # # Verify we get a valid game state response - # assert game_state["state"] == State.BLIND_SELECT.value - - # Go back to menu - teardown_test(udp_client) - - -def test_invalid_blind_action(udp_client: socket.socket) -> None: - """Test that invalid blind action arguments are handled properly.""" - # First start a run to get to blind select state - start_run_args = { - "deck": "Red Deck", - "stake": 1, - "challenge": None, - "seed": "INVALID_ACTION", - } - send_api_message(udp_client, "start_run", start_run_args) - - # Wait for the run to start and reach blind select state - game_state = receive_api_message(udp_client) - assert game_state["state"] == State.BLIND_SELECT.value - - # Send invalid action - invalid_args = {"action": "invalid_action"} - send_api_message(udp_client, "skip_or_select_blind", invalid_args) - - # Should receive error response - error_response = receive_api_message(udp_client) - - # Verify error response - assert isinstance(error_response, dict) - assert "error" in error_response - assert "Invalid action arg" in error_response["error"] - - # Go back to menu - teardown_test(udp_client) - - -def test_game_state_during_run(udp_client: socket.socket) -> None: - """Test getting game state at different points during a run.""" - # Start a run - start_run_args = { - "deck": "Red Deck", - "stake": 1, - "challenge": None, - "seed": "STATE_TEST", - } - send_api_message(udp_client, "start_run", start_run_args) - - # Get initial state - initial_state = receive_api_message(udp_client) - assert initial_state["state"] == State.BLIND_SELECT.value - - # Get game state again to ensure it's consistent - send_api_message(udp_client, "get_game_state", {}) - current_state = receive_api_message(udp_client) - - assert current_state["state"] == State.BLIND_SELECT.value - assert current_state["state"] == initial_state["state"] - - # Go back to menu - teardown_test(udp_client) - - -def test_start_run_missing_required_args(udp_client: socket.socket) -> None: - """Test start_run with missing required arguments.""" - # Missing deck - incomplete_args = { - "stake": 1, - "challenge": None, - "seed": "EXAMPLE", - } - send_api_message(udp_client, "start_run", incomplete_args) - - # Should receive error response - response = receive_api_message(udp_client) - assert isinstance(response, dict) - assert "error" in response - assert "Invalid deck arg" in response["error"] - - -def test_start_run_invalid_deck(udp_client: socket.socket) -> None: - """Test start_run with invalid deck name.""" - invalid_args = { - "deck": "Nonexistent Deck", - "stake": 1, - "challenge": None, - "seed": "EXAMPLE", - } - send_api_message(udp_client, "start_run", invalid_args) - - # Should receive error response - response = receive_api_message(udp_client) - assert isinstance(response, dict) - assert "error" in response - assert "Invalid deck arg" in response["error"] - - -def test_go_to_menu(udp_client: socket.socket) -> None: - """Test going to the main menu.""" - send_api_message(udp_client, "go_to_menu", {}) - - game_state = receive_api_message(udp_client) - assert game_state["state"] == State.MENU.value - - -def test_go_to_menu_from_run(udp_client: socket.socket) -> None: - """Test going to menu from within a run.""" - # First start a run - start_run_args = { - "deck": "Red Deck", - "stake": 1, - "challenge": None, - "seed": "MENU_TEST", - } - send_api_message(udp_client, "start_run", start_run_args) - - # Wait for the run to start - initial_state = receive_api_message(udp_client) - assert initial_state["state"] == State.BLIND_SELECT.value - - # Now go to menu - send_api_message(udp_client, "go_to_menu", {}) - - # Wait for the game to confirm we've reached the menu - menu_state = receive_api_message(udp_client) - - assert menu_state["state"] == State.MENU.value + @pytest.fixture(autouse=True) + def setup_and_teardown( + self, udp_client: socket.socket + ) -> Generator[None, None, None]: + """Set up and tear down each test method.""" + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "BLIND_TEST", + } + game_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) + assert game_state["state"] == State.BLIND_SELECT.value + yield + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + def test_select_blind(self, udp_client: socket.socket) -> None: + """Test selecting a blind during the blind selection phase.""" + # Select the blind + select_blind_args = {"action": "select"} + game_state = send_and_receive_api_message( + udp_client, "skip_or_select_blind", select_blind_args + ) + + # Verify we get a valid game state response + assert game_state["state"] == State.SELECTING_HAND.value + + # Assert that there are 8 cards in the hand + assert len(game_state["hand"]) == 8 + + def test_skip_blind(self, udp_client: socket.socket) -> None: + """Test skipping a blind during the blind selection phase.""" + # Skip the blind + skip_blind_args = {"action": "skip"} + game_state = send_and_receive_api_message( + udp_client, "skip_or_select_blind", skip_blind_args + ) + + # Verify we get a valid game state response + # Note: State after skipping might vary depending on game logic + + def test_invalid_blind_action(self, udp_client: socket.socket) -> None: + """Test that invalid blind action arguments are handled properly.""" + # Should receive error response + error_response = send_and_receive_api_message( + udp_client, "skip_or_select_blind", {"action": "invalid_action"} + ) + + # Verify error response + assert isinstance(error_response, dict) + assert "error" in error_response + assert "Invalid action arg" in error_response["error"] diff --git a/tests/test_connection.py b/tests/test_connection.py index d65f9e7..d201c23 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -4,7 +4,7 @@ import socket import pytest -from conftest import BUFFER_SIZE, HOST, PORT, TIMEOUT, send_api_message +from conftest import BUFFER_SIZE, HOST, PORT, send_api_message def test_basic_connection(udp_client: socket.socket) -> None: From 6b113b0dc6e4f918b9d56d8e61c4980410ea5892 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 11:49:06 +0200 Subject: [PATCH 09/60] feat: add blind_on_deck to game_state and ftm code --- src/lua/utils.lua | 161 +++++++++++++++++++++++----------------------- 1 file changed, 81 insertions(+), 80 deletions(-) diff --git a/src/lua/utils.lua b/src/lua/utils.lua index a67c384..31c3ddf 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -2,97 +2,98 @@ utils = {} local json = require("json") function utils.get_game_state() - local game = nil - if G.GAME then - game = { - hands_played = G.GAME.hands_played, - skips = G.GAME.Skips, - round = G.GAME.round, - discount_percent = G.GAME.discount_percent, - interest_cap = G.GAME.interest_cap, - inflation = G.GAME.inflation, - dollars = G.GAME.dollars, - max_jokers = G.GAME.max_jokers, - bankrupt_at = G.GAME.bankrupt_at, - chips = G.GAME.chips, - current_round = { - discards_left = G.GAME.current_round.discards_left, - }, - } - end + local game = nil + if G.GAME then + game = { + hands_played = G.GAME.hands_played, + skips = G.GAME.Skips, + round = G.GAME.round, + discount_percent = G.GAME.discount_percent, + interest_cap = G.GAME.interest_cap, + inflation = G.GAME.inflation, + dollars = G.GAME.dollars, + max_jokers = G.GAME.max_jokers, + bankrupt_at = G.GAME.bankrupt_at, + chips = G.GAME.chips, + blind_on_deck = G.GAME.blind_on_deck, + current_round = { + discards_left = G.GAME.current_round.discards_left, + }, + } + end - local hand = nil - if G.hand then - hand = {} - for i, card in pairs(G.hand.cards) do - hand[i] = { - label = card.label, - config = { - card = { - name = card.config.card.name, - suit = card.config.card.suit, - value = card.config.card.value, - card_key = card.config.card_key, - }, - }, - } - end - end + local hand = nil + if G.hand then + hand = {} + for i, card in pairs(G.hand.cards) do + hand[i] = { + label = card.label, + config = { + card = { + name = card.config.card.name, + suit = card.config.card.suit, + value = card.config.card.value, + card_key = card.config.card_key, + }, + }, + } + end + end - local jokers = {} - if G.jokers and G.jokers.cards then - for i, card in pairs(G.jokers.cards) do - jokers[i] = { - label = card.label, - config = { - center = card.config.center, - }, - } - end - end + local jokers = {} + if G.jokers and G.jokers.cards then + for i, card in pairs(G.jokers.cards) do + jokers[i] = { + label = card.label, + config = { + center = card.config.center, + }, + } + end + end - -- TODO: add consumables, ante, and shop + -- TODO: add consumables, ante, and shop - return { - state = G.STATE, - game = game, - hand = hand, - jokers = jokers, - } + return { + state = G.STATE, + game = game, + hand = hand, + jokers = jokers, + } end function utils.table_to_json(obj, depth) - depth = depth or 3 + depth = depth or 3 - local function sanitize_for_json(value, current_depth) - if current_depth <= 0 then - return "..." - end + local function sanitize_for_json(value, current_depth) + if current_depth <= 0 then + return "..." + end - local value_type = type(value) + local value_type = type(value) - if value_type == "nil" then - return nil - elseif value_type == "string" or value_type == "number" or value_type == "boolean" then - return value - elseif value_type == "function" then - return "function" - elseif value_type == "userdata" then - return "userdata" - elseif value_type == "table" then - local sanitized = {} - for k, v in pairs(value) do - local key = type(k) == "string" and k or tostring(k) - sanitized[key] = sanitize_for_json(v, current_depth - 1) - end - return sanitized - else - return tostring(value) - end - end + if value_type == "nil" then + return nil + elseif value_type == "string" or value_type == "number" or value_type == "boolean" then + return value + elseif value_type == "function" then + return "function" + elseif value_type == "userdata" then + return "userdata" + elseif value_type == "table" then + local sanitized = {} + for k, v in pairs(value) do + local key = type(k) == "string" and k or tostring(k) + sanitized[key] = sanitize_for_json(v, current_depth - 1) + end + return sanitized + else + return tostring(value) + end + end - local sanitized = sanitize_for_json(obj, depth) - return json.encode(sanitized) + local sanitized = sanitize_for_json(obj, depth) + return json.encode(sanitized) end return utils From 6fece79e239a6fd11c67ae9b1598ee826f4020da Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 11:55:05 +0200 Subject: [PATCH 10/60] chore: add editorconfig for consistent formatting --- .editorconfig | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3095c17 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 +continuation_indent = 2 +quote_style = double +max_line_length = 120 +charset = utf-8 +call_parentheses = true From 10add4d9ea62fa29c4450d2465c0f98c2e600c8f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 11:55:52 +0200 Subject: [PATCH 11/60] style: format *.lua using editorconfig specs --- balatrobot.lua | 38 ++--- src/lua/api.lua | 366 ++++++++++++++++++++++++------------------------ 2 files changed, 202 insertions(+), 202 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 5a059a4..4abd471 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -10,16 +10,16 @@ ---Global configuration for the BalatroBot mod ---@type BalatrobotConfig BALATRO_BOT_CONFIG = { - port = "12346", - dt = 8.0 / 60.0, - max_fps = 60, - vsync_enabled = false, - - -- -- Default values for the original game - -- port = "12346", - -- dt = 1.0 / 60.0, - -- vsync_enabled = true, - -- max_fps = nil, + port = "12346", + dt = 2.0 / 60.0, + max_fps = 60, + vsync_enabled = false, + + -- -- Default values for the original game + -- port = "12346", + -- dt = 1.0 / 60.0, + -- vsync_enabled = true, + -- max_fps = nil, } -- Load debug @@ -30,15 +30,15 @@ assert(SMODS.load_file("src/lua/utils.lua"))() assert(SMODS.load_file("src/lua/api.lua"))() if success and dpAPI.isVersionCompatible(1) then - local debugplus = dpAPI.registerID("balatrobot") - debugplus.addCommand({ - name = "env", - shortDesc = "Get game state", - desc = "Get the current game state, useful for debugging", - exec = function(args, rawArgs, dp) - debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G, 2) .. "}") - end, - }) + local debugplus = dpAPI.registerID("balatrobot") + debugplus.addCommand({ + name = "env", + shortDesc = "Get game state", + desc = "Get the current game state, useful for debugging", + exec = function(args, _, _) + debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G, 2) .. "}") + end, + }) end -- Initialize API diff --git a/src/lua/api.lua b/src/lua/api.lua index 930c07d..22b9838 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -13,84 +13,84 @@ API.last_client_port = nil -------------------------------------------------------------------------------- function API.update(dt) - -- Create socket if it doesn't exist - if not API.socket then - API.socket = socket.udp() - API.socket:settimeout(0) - local port = BALATRO_BOT_CONFIG.port - API.socket:setsockname("127.0.0.1", tonumber(port)) - sendDebugMessage("UDP socket created on port " .. port, "BALATROBOT") - end - - -- Process pending requests - for key, request in pairs(API.pending_requests) do - if request.condition(request.args) then - request.action(request.args) - API.pending_requests[key] = nil - end - end - - -- Parse received data and run the appropriate function - local raw_data, client_ip, client_port = API.socket:receivefrom(65536) - if raw_data and client_ip and client_port then - -- Store the last client connection - API.last_client_ip = client_ip - API.last_client_port = client_port - - sendDebugMessage("Received data from " .. client_ip .. ":" .. client_port, "BALATROBOT") - local ok, data = pcall(json.decode, raw_data) - if not ok then - sendErrorMessage("Invalid JSON", "BALATROBOT") - API.send_response({ error = "Invalid JSON" }) - return - end - if data.name == nil then - sendErrorMessage("Message must contain a name", "BALATROBOT") - API.send_response({ error = "Message must contain a name" }) - elseif data.arguments == nil then - sendErrorMessage("Message must contain arguments", "BALATROBOT") - API.send_response({ error = "Message must contain arguments" }) - else - local func = API.functions[data.name] - local args = data.arguments - if func == nil then - sendErrorMessage("Unknown function name: " .. data.name, "BALATROBOT") - API.send_response({ error = "Unknown function name: " .. data.name }) - elseif type(args) ~= "table" then - sendErrorMessage("Arguments must be a table", "BALATROBOT") - API.send_response({ error = "Arguments must be a table: " .. type(args) }) - else - func(args) - end - end - elseif client_ip ~= "timeout" then - sendErrorMessage("UDP error: " .. tostring(client_ip), "BALATROBOT") - end + -- Create socket if it doesn't exist + if not API.socket then + API.socket = socket.udp() + API.socket:settimeout(0) + local port = BALATRO_BOT_CONFIG.port + API.socket:setsockname("127.0.0.1", tonumber(port)) + sendDebugMessage("UDP socket created on port " .. port, "BALATROBOT") + end + + -- Process pending requests + for key, request in pairs(API.pending_requests) do + if request.condition(request.args) then + request.action(request.args) + API.pending_requests[key] = nil + end + end + + -- Parse received data and run the appropriate function + local raw_data, client_ip, client_port = API.socket:receivefrom(65536) + if raw_data and client_ip and client_port then + -- Store the last client connection + API.last_client_ip = client_ip + API.last_client_port = client_port + + sendDebugMessage("Received data from " .. client_ip .. ":" .. client_port, "BALATROBOT") + local ok, data = pcall(json.decode, raw_data) + if not ok then + sendErrorMessage("Invalid JSON", "BALATROBOT") + API.send_response({ error = "Invalid JSON" }) + return + end + if data.name == nil then + sendErrorMessage("Message must contain a name", "BALATROBOT") + API.send_response({ error = "Message must contain a name" }) + elseif data.arguments == nil then + sendErrorMessage("Message must contain arguments", "BALATROBOT") + API.send_response({ error = "Message must contain arguments" }) + else + local func = API.functions[data.name] + local args = data.arguments + if func == nil then + sendErrorMessage("Unknown function name: " .. data.name, "BALATROBOT") + API.send_response({ error = "Unknown function name: " .. data.name }) + elseif type(args) ~= "table" then + sendErrorMessage("Arguments must be a table", "BALATROBOT") + API.send_response({ error = "Arguments must be a table: " .. type(args) }) + else + func(args) + end + end + elseif client_ip ~= "timeout" then + sendErrorMessage("UDP error: " .. tostring(client_ip), "BALATROBOT") + end end function API.send_response(response) - if API.last_client_ip and API.last_client_port then - API.socket:sendto(json.encode(response), API.last_client_ip, API.last_client_port) - end + if API.last_client_ip and API.last_client_port then + API.socket:sendto(json.encode(response), API.last_client_ip, API.last_client_port) + end end function API.init() - -- Hook into the game's update loop - local original_update = love.update - love.update = function(dt) - original_update(BALATRO_BOT_CONFIG.dt) - API.update(BALATRO_BOT_CONFIG.dt) - end + -- Hook into the game's update loop + local original_update = love.update + love.update = function(dt) + original_update(BALATRO_BOT_CONFIG.dt) + API.update(BALATRO_BOT_CONFIG.dt) + end - if not BALATRO_BOT_CONFIG.vsync_enabled then - love.window.setVSync(0) - end + if not BALATRO_BOT_CONFIG.vsync_enabled then + love.window.setVSync(0) + end - if BALATRO_BOT_CONFIG.max_fps then - G.FPS_CAP = 60 - end + if BALATRO_BOT_CONFIG.max_fps then + G.FPS_CAP = 60 + end - sendInfoMessage("BalatrobotAPI initialized", "BALATROBOT") + sendInfoMessage("BalatrobotAPI initialized", "BALATROBOT") end -------------------------------------------------------------------------------- @@ -98,156 +98,156 @@ end -------------------------------------------------------------------------------- API.functions["get_game_state"] = function(args) - local game_state = utils.get_game_state() - API.send_response(game_state) + local game_state = utils.get_game_state() + API.send_response(game_state) end API.functions["go_to_menu"] = function(args) - if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then - sendDebugMessage("go_to_menu called but already in menu", "BALATROBOT") - local game_state = utils.get_game_state() - API.send_response(game_state) - return - end - - G.FUNCS.go_to_menu({}) - API.pending_requests["go_to_menu"] = { - condition = function() - return G.STATE == G.STATES.MENU and G.MAIN_MENU_UI - end, - action = function(args) - local game_state = utils.get_game_state() - API.send_response(game_state) - end, - args = args, - } + if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then + sendDebugMessage("go_to_menu called but already in menu", "BALATROBOT") + local game_state = utils.get_game_state() + API.send_response(game_state) + return + end + + G.FUNCS.go_to_menu({}) + API.pending_requests["go_to_menu"] = { + condition = function() + return G.STATE == G.STATES.MENU and G.MAIN_MENU_UI + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } end API.functions["start_run"] = function(args) - -- Reset the game - local play_button = G.MAIN_MENU_UI:get_UIE_by_ID("main_menu_play") - G.FUNCS[play_button.config.button]({ config = {} }) - G.FUNCS.exit_overlay_menu({}) - - -- Set the deck - local deck_found = false - for _, v in pairs(G.P_CENTER_POOLS.Back) do - if v.name == args.deck then - sendDebugMessage("Changing to deck: " .. v.name, "BALATROBOT") - G.GAME.selected_back:change_to(v) - G.GAME.viewed_back:change_to(v) - deck_found = true - break - end - end - if not deck_found then - sendErrorMessage("Invalid deck arg for start_run: " .. tostring(args.deck), "BALATROBOT") - API.send_response({ error = "Invalid deck arg for start_run: " .. tostring(args.deck) }) - return - end - - -- Set the challenge - local challenge_obj = nil - if args.challenge then - for i = 1, #G.CHALLENGES do - if G.CHALLENGES[i].name == args.challenge then - challenge_obj = G.CHALLENGES[i] - break - end - end - end - G.GAME.challenge_name = args.challenge - - -- Start the run - G.FUNCS.start_run(nil, { stake = args.stake, seed = args.seed, challenge = challenge_obj }) - - -- Defer sending response until the run has started - API.pending_requests["start_run"] = { - condition = function() - return G.STATE == G.STATES.BLIND_SELECT and G.GAME.blind_on_deck - end, - action = function(args) - local game_state = utils.get_game_state() - API.send_response(game_state) - end, - } + -- Reset the game + local play_button = G.MAIN_MENU_UI:get_UIE_by_ID("main_menu_play") + G.FUNCS[play_button.config.button]({ config = {} }) + G.FUNCS.exit_overlay_menu({}) + + -- Set the deck + local deck_found = false + for _, v in pairs(G.P_CENTER_POOLS.Back) do + if v.name == args.deck then + sendDebugMessage("Changing to deck: " .. v.name, "BALATROBOT") + G.GAME.selected_back:change_to(v) + G.GAME.viewed_back:change_to(v) + deck_found = true + break + end + end + if not deck_found then + sendErrorMessage("Invalid deck arg for start_run: " .. tostring(args.deck), "BALATROBOT") + API.send_response({ error = "Invalid deck arg for start_run: " .. tostring(args.deck) }) + return + end + + -- Set the challenge + local challenge_obj = nil + if args.challenge then + for i = 1, #G.CHALLENGES do + if G.CHALLENGES[i].name == args.challenge then + challenge_obj = G.CHALLENGES[i] + break + end + end + end + G.GAME.challenge_name = args.challenge + + -- Start the run + G.FUNCS.start_run(nil, { stake = args.stake, seed = args.seed, challenge = challenge_obj }) + + -- Defer sending response until the run has started + API.pending_requests["start_run"] = { + condition = function() + return G.STATE == G.STATES.BLIND_SELECT and G.GAME.blind_on_deck + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } end API.functions["skip_or_select_blind"] = function(args) - local current_blind = G.GAME.blind_on_deck - local blind_obj = G.blind_select_opts[string.lower(current_blind)] - if args.action == "select" then - button = blind_obj:get_UIE_by_ID("select_blind_button") - G.FUNCS[button.config.button](button) - API.pending_requests["skip_or_select_blind"] = { - condition = function() - return G.GAME and G.GAME.facing_blind and G.STATE == G.STATES.SELECTING_HAND - end, - action = function(args) - local game_state = utils.get_game_state() - API.send_response(game_state) - end, - args = args, - } - elseif args.action == "skip" then - button = blind_obj:get_UIE_by_ID("tag_" .. current_blind).children[2] - G.FUNCS[button.config.button](button) - API.pending_requests["skip_or_select_blind"] = { - condition = function() - local prev_state = { - ["Small"] = G.prev_small_state, - ["Large"] = G.prev_large_state, - ["Boss"] = G.prev_boss_state, - } - return prev_state[current_blind] == "Skipped" - end, - action = function(args) - local game_state = utils.get_game_state() - API.send_response(game_state) - end, - args = args, - } - else - sendErrorMessage("Invalid action arg for skip_or_select_blind: " .. args.action, "BALATROBOT") - API.send_response({ error = "Invalid action arg for skip_or_select_blind: " .. args.action }) - return - end + local current_blind = G.GAME.blind_on_deck + local blind_obj = G.blind_select_opts[string.lower(current_blind)] + if args.action == "select" then + button = blind_obj:get_UIE_by_ID("select_blind_button") + G.FUNCS[button.config.button](button) + API.pending_requests["skip_or_select_blind"] = { + condition = function() + return G.GAME and G.GAME.facing_blind and G.STATE == G.STATES.SELECTING_HAND + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } + elseif args.action == "skip" then + button = blind_obj:get_UIE_by_ID("tag_" .. current_blind).children[2] + G.FUNCS[button.config.button](button) + API.pending_requests["skip_or_select_blind"] = { + condition = function() + local prev_state = { + ["Small"] = G.prev_small_state, + ["Large"] = G.prev_large_state, + ["Boss"] = G.prev_boss_state, + } + return prev_state[current_blind] == "Skipped" + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } + else + sendErrorMessage("Invalid action arg for skip_or_select_blind: " .. args.action, "BALATROBOT") + API.send_response({ error = "Invalid action arg for skip_or_select_blind: " .. args.action }) + return + end end API.functions["play_cards"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["discard_cards"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["select_booster_action"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["select_shop_action"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["rearrange_hand"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["rearrange_consumables"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["rearrange_jokers"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["use_or_sell_consumables"] = function(args) - -- TODO: implement + -- TODO: implement end API.functions["sell_jokers"] = function(args) - -- TODO: implement + -- TODO: implement end return API From b1c84fe86814fed728a7756d16fa7b52fb049e42 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 11:56:26 +0200 Subject: [PATCH 12/60] test(api): add assert for skip blind test --- tests/test_api_functions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 28ef5a2..fb1d8f4 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -218,7 +218,10 @@ def test_skip_blind(self, udp_client: socket.socket) -> None: ) # Verify we get a valid game state response - # Note: State after skipping might vary depending on game logic + assert game_state["state"] == State.BLIND_SELECT.value + + # Assert that the current blind is "Big", the "Small" blind was skipped + assert game_state["game"]["blind_on_deck"] == "Big" def test_invalid_blind_action(self, udp_client: socket.socket) -> None: """Test that invalid blind action arguments are handled properly.""" From 779d962e5f2da2808cb0e2312206c96a863f9fa8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 13:42:16 +0200 Subject: [PATCH 13/60] chore: add suggested extensions for vscode-like editors --- .vscode/extensions.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1f4ce5b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "sumneko.lua", + "charliermarsh.ruff", + "detachhead.basedpyright", + "ms-python.vscode-pylance", + "ms-python.python", + "ms-python.debugpy" + ] +} From e8568b9c6e1289efdf9541e3e53e053df0649e3f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 14:27:52 +0200 Subject: [PATCH 14/60] test: use "EXAMPLE" seed for all tests --- tests/test_api_functions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index fb1d8f4..4caf2bc 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -43,7 +43,7 @@ def test_game_state_during_run(self, udp_client: socket.socket) -> None: "deck": "Red Deck", "stake": 1, "challenge": None, - "seed": "STATE_TEST", + "seed": "EXAMPLE", } initial_state = send_and_receive_api_message( udp_client, "start_run", start_run_args @@ -88,7 +88,7 @@ def test_start_run_with_challenge(self, udp_client: socket.socket) -> None: "deck": "Red Deck", "stake": 1, "challenge": "The Omelette", - "seed": "CHALLENGE_TEST", + "seed": "EXAMPLE", } game_state = send_and_receive_api_message( udp_client, "start_run", start_run_args @@ -104,7 +104,7 @@ def test_start_run_different_stakes(self, udp_client: socket.socket) -> None: "deck": "Red Deck", "stake": stake, "challenge": None, - "seed": f"STAKE_{stake}", + "seed": "EXAMPLE", } game_state = send_and_receive_api_message( udp_client, "start_run", start_run_args @@ -161,7 +161,7 @@ def test_go_to_menu_from_run(self, udp_client: socket.socket) -> None: "deck": "Red Deck", "stake": 1, "challenge": None, - "seed": "MENU_TEST", + "seed": "EXAMPLE", } initial_state = send_and_receive_api_message( udp_client, "start_run", start_run_args @@ -186,7 +186,7 @@ def setup_and_teardown( "deck": "Red Deck", "stake": 1, "challenge": None, - "seed": "BLIND_TEST", + "seed": "EXAMPLE", } game_state = send_and_receive_api_message( udp_client, "start_run", start_run_args From 8008430fa87bd8d352c5dcde78ae29fae616b367 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 16:32:25 +0200 Subject: [PATCH 15/60] fix: key for G.GAME.skips --- src/lua/utils.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lua/utils.lua b/src/lua/utils.lua index 31c3ddf..7173462 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -6,7 +6,7 @@ function utils.get_game_state() if G.GAME then game = { hands_played = G.GAME.hands_played, - skips = G.GAME.Skips, + skips = G.GAME.skips, round = G.GAME.round, discount_percent = G.GAME.discount_percent, interest_cap = G.GAME.interest_cap, From fd5d9ad26b1445eab4eca2e75e35dcd712b0f493 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 17:14:38 +0200 Subject: [PATCH 16/60] fix: lua type for BalatrobotConfig Make max_fps optional --- balatrobot.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 4abd471..774d12b 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -4,14 +4,14 @@ ---@class BalatrobotConfig ---@field port string Port for the bot to listen on ---@field dt number Tells the game that every update is dt seconds long. ----@field max_fps integer Maximum frames per second +---@field max_fps integer? Maximum frames per second ---@field vsync_enabled boolean Whether vertical sync is enabled ---Global configuration for the BalatroBot mod ---@type BalatrobotConfig BALATRO_BOT_CONFIG = { port = "12346", - dt = 2.0 / 60.0, + dt = 8.0 / 60.0, max_fps = 60, vsync_enabled = false, @@ -19,7 +19,7 @@ BALATRO_BOT_CONFIG = { -- port = "12346", -- dt = 1.0 / 60.0, -- vsync_enabled = true, - -- max_fps = nil, + -- max_fps = nil, } -- Load debug @@ -36,7 +36,7 @@ if success and dpAPI.isVersionCompatible(1) then shortDesc = "Get game state", desc = "Get the current game state, useful for debugging", exec = function(args, _, _) - debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G, 2) .. "}") + debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G.GAME, 2) .. "}") end, }) end From fe3458c108639f621af553b6dd09679607385467 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 17:15:19 +0200 Subject: [PATCH 17/60] feat(api): implement play_hand_or_discard action --- src/lua/api.lua | 47 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index 22b9838..deb0f47 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -214,8 +214,51 @@ API.functions["skip_or_select_blind"] = function(args) end end -API.functions["play_cards"] = function(args) - -- TODO: implement +API.functions["play_hand_or_discard"] = function(args) + -- adjust from 0-based to 1-based indexing + for i, card_index in ipairs(args.cards) do + args.cards[i] = card_index + 1 + end + + -- Check that all cards are selectable + for _, card_index in ipairs(args.cards) do + if not G.hand.cards[card_index] then + sendErrorMessage("Invalid card index: " .. tostring(card_index), "BALATROBOT") + API.send_response({ error = "Invalid card index: " .. tostring(card_index) }) + return + end + end + + -- Select cards + for _, card_index in ipairs(args.cards) do + G.hand.cards[card_index]:click() + end + + if args.action == "play_hand" then + ---@diagnostic disable-next-line: undefined-field + local play_button = UIBox:get_UIE_by_ID("play_button", G.buttons.UIRoot) + G.FUNCS["play_cards_from_highlighted"](play_button) + elseif args.action == "discard" then + ---@diagnostic disable-next-line: undefined-field + local discard_button = UIBox:get_UIE_by_ID("discard_button", G.buttons.UIRoot) + G.FUNCS["discard_cards_from_highlighted"](discard_button) + else + sendErrorMessage("Invalid action arg for play_hand_or_discard: " .. args.action, "BALATROBOT") + API.send_response({ error = "Invalid action arg for play_hand_or_discard: " .. args.action }) + return + end + + -- Defer sending response until the run has started + API.pending_requests["play_hand_or_discard"] = { + condition = function() + -- TODO: remove brittle G.E_MANAGER check + return G.buttons and G.STATE_COMPLETE and G.STATE == G.STATES.SELECTING_HAND and #G.E_MANAGER.queues.base < 3 + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } end API.functions["discard_cards"] = function(args) From a917b1dacecab2da21df370266a3d77b6a6845db Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 17:15:52 +0200 Subject: [PATCH 18/60] test(api): add tests for play_hand_or_discard action --- tests/test_api_functions.py | 131 ++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 4caf2bc..1785927 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -234,3 +234,134 @@ def test_invalid_blind_action(self, udp_client: socket.socket) -> None: assert isinstance(error_response, dict) assert "error" in error_response assert "Invalid action arg" in error_response["error"] + + +class TestPlayHandOrDiscard: + """Tests for the play_hand_or_discard API endpoint.""" + + @pytest.fixture(autouse=True) + def setup_and_teardown( + self, udp_client: socket.socket + ) -> Generator[dict, None, None]: + """Set up and tear down each test method.""" + send_and_receive_api_message( + udp_client, + "start_run", + { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "EXAMPLE", + }, + ) + game_state = send_and_receive_api_message( + udp_client, + "skip_or_select_blind", + {"action": "select"}, + ) + assert game_state["state"] == State.SELECTING_HAND.value + yield game_state + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + @pytest.mark.parametrize( + "cards,expected_new_cards", + [ + ([0, 1, 2, 3, 4], 5), # Test playing five cards + ([0], 1), # Test playing one card + ], + ) + def test_play_hand( + self, + udp_client: socket.socket, + setup_and_teardown: dict, + cards: list[int], + expected_new_cards: int, + ) -> None: + """Test playing a hand with different numbers of cards.""" + initial_game_state = setup_and_teardown + play_hand_args = {"action": "play_hand", "cards": cards} + + init_card_keys = [ + card["config"]["card"]["card_key"] for card in initial_game_state["hand"] + ] + played_hand_keys = [ + initial_game_state["hand"][i]["config"]["card"]["card_key"] + for i in play_hand_args["cards"] + ] + game_state = send_and_receive_api_message( + udp_client, "play_hand_or_discard", play_hand_args + ) + final_card_keys = [ + card["config"]["card"]["card_key"] for card in game_state["hand"] + ] + assert game_state["state"] == State.SELECTING_HAND.value + assert game_state["game"]["hands_played"] == 1 + assert len(set(final_card_keys) - set(init_card_keys)) == expected_new_cards + assert set(final_card_keys) & set(played_hand_keys) == set() + + def test_play_hand_invalid_cards(self, udp_client: socket.socket) -> None: + """Test playing a hand with invalid card indices returns error.""" + play_hand_args = {"action": "play_hand", "cards": [10, 11, 12, 13, 14]} + response = send_and_receive_api_message( + udp_client, "play_hand_or_discard", play_hand_args + ) + + # Should receive error response for invalid card index + assert isinstance(response, dict) + assert "error" in response + assert "Invalid card index" in response["error"] + + def test_play_hand_invalid_action(self, udp_client: socket.socket) -> None: + """Test playing a hand with invalid action returns error.""" + play_hand_args = {"action": "invalid_action", "cards": [0, 1, 2, 3, 4]} + response = send_and_receive_api_message( + udp_client, "play_hand_or_discard", play_hand_args + ) + + # Should receive error response for invalid action + assert isinstance(response, dict) + assert "error" in response + assert "Invalid action arg" in response["error"] + + @pytest.mark.parametrize( + "cards,expected_new_cards", + [ + ([0, 1, 2, 3, 4], 5), # Test discarding five cards + ([0], 1), # Test discarding one card + ], + ) + def test_discard( + self, + udp_client: socket.socket, + setup_and_teardown: dict, + cards: list[int], + expected_new_cards: int, + ) -> None: + """Test discarding with different numbers of cards.""" + initial_game_state = setup_and_teardown + init_discards_left = initial_game_state["game"]["current_round"][ + "discards_left" + ] + discard_hand_args = {"action": "discard", "cards": cards} + + init_card_keys = [ + card["config"]["card"]["card_key"] for card in initial_game_state["hand"] + ] + discarded_hand_keys = [ + initial_game_state["hand"][i]["config"]["card"]["card_key"] + for i in discard_hand_args["cards"] + ] + game_state = send_and_receive_api_message( + udp_client, "play_hand_or_discard", discard_hand_args + ) + final_card_keys = [ + card["config"]["card"]["card_key"] for card in game_state["hand"] + ] + assert game_state["state"] == State.SELECTING_HAND.value + assert game_state["game"]["hands_played"] == 0 + assert ( + game_state["game"]["current_round"]["discards_left"] + == init_discards_left - 1 + ) + assert len(set(final_card_keys) - set(init_card_keys)) == expected_new_cards + assert set(final_card_keys) & set(discarded_hand_keys) == set() From 9cbefe67fd6c962fa41e81fb65963b0a23b6016c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 18:53:16 +0200 Subject: [PATCH 19/60] chore: add simple script to gitignore These are script useful for debugging that have been generated oneshot with LLM. They are not commit-quality. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 198124d..e40e179 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ gamestate_cache *.log src/lua_old balatrobot_old.lua +scripts From 83165881feed8c19746faab52deca5f0521b3445 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 18:56:46 +0200 Subject: [PATCH 20/60] chore(api): remove discard funciton This action is already implemented in play_hand_or_discard action --- src/lua/api.lua | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index deb0f47..e3c4886 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -261,10 +261,6 @@ API.functions["play_hand_or_discard"] = function(args) } end -API.functions["discard_cards"] = function(args) - -- TODO: implement -end - API.functions["select_booster_action"] = function(args) -- TODO: implement end From fd7240a5ac600c611520e1a47112f6d6508b9e08 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 20:00:45 +0200 Subject: [PATCH 21/60] feat(api): handle winning a round in play_hand_or_discard --- src/lua/api.lua | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index e3c4886..0465f82 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -251,8 +251,21 @@ API.functions["play_hand_or_discard"] = function(args) -- Defer sending response until the run has started API.pending_requests["play_hand_or_discard"] = { condition = function() - -- TODO: remove brittle G.E_MANAGER check - return G.buttons and G.STATE_COMPLETE and G.STATE == G.STATES.SELECTING_HAND and #G.E_MANAGER.queues.base < 3 + -- TODO: maybe remove brittle G.E_MANAGER check + if #G.E_MANAGER.queues.base < 3 and G.STATE_COMPLETE then + -- round still going + if G.buttons and G.STATE == G.STATES.SELECTING_HAND then + return true + + -- round won and entering cash out state (ROUND_EVAL state) + elseif G.STATE == G.STATES.ROUND_EVAL then + return true + + -- TODO: round lost (back to MAIN MENU) + -- TODO: round lost ("you lost" banner) + end + end + return false end, action = function(args) local game_state = utils.get_game_state() From 248eec84759c4df7a0b1d87f1a2d4705f115f32b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 20:01:25 +0200 Subject: [PATCH 22/60] test(api): add test for play winning hand --- tests/test_api_functions.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 1785927..7c503fc 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -251,7 +251,7 @@ def setup_and_teardown( "deck": "Red Deck", "stake": 1, "challenge": None, - "seed": "EXAMPLE", + "seed": "OOOO155", # four of a kind in first hand }, ) game_state = send_and_receive_api_message( @@ -266,7 +266,7 @@ def setup_and_teardown( @pytest.mark.parametrize( "cards,expected_new_cards", [ - ([0, 1, 2, 3, 4], 5), # Test playing five cards + ([7, 6, 5, 4, 3], 5), # Test playing five cards ([0], 1), # Test playing one card ], ) @@ -299,6 +299,17 @@ def test_play_hand( assert len(set(final_card_keys) - set(init_card_keys)) == expected_new_cards assert set(final_card_keys) & set(played_hand_keys) == set() + def test_play_hand_winning( + self, udp_client: socket.socket, setup_and_teardown: dict + ) -> None: + """Test playing a winning hand (four of a kind)""" + _ = setup_and_teardown + play_hand_args = {"action": "play_hand", "cards": [0, 1, 2, 3]} + game_state = send_and_receive_api_message( + udp_client, "play_hand_or_discard", play_hand_args + ) + assert game_state["state"] == State.ROUND_EVAL.value + def test_play_hand_invalid_cards(self, udp_client: socket.socket) -> None: """Test playing a hand with invalid card indices returns error.""" play_hand_args = {"action": "play_hand", "cards": [10, 11, 12, 13, 14]} From 7c681304460fe2683087e735fd71b0c84612936c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 22:16:00 +0200 Subject: [PATCH 23/60] fix: reduce default mod dt to 4/60 --- balatrobot.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/balatrobot.lua b/balatrobot.lua index 774d12b..1f7324b 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -11,7 +11,7 @@ ---@type BalatrobotConfig BALATRO_BOT_CONFIG = { port = "12346", - dt = 8.0 / 60.0, + dt = 4.0 / 60.0, -- value >= 4.0 make mod instable max_fps = 60, vsync_enabled = false, From 34ba7c8364ecfd79f14cc60fa934109dca0d49d8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 22:17:15 +0200 Subject: [PATCH 24/60] feat(api): improve logging for function calls --- src/lua/api.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index 0465f82..c5337f3 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -37,7 +37,6 @@ function API.update(dt) API.last_client_ip = client_ip API.last_client_port = client_port - sendDebugMessage("Received data from " .. client_ip .. ":" .. client_port, "BALATROBOT") local ok, data = pcall(json.decode, raw_data) if not ok then sendErrorMessage("Invalid JSON", "BALATROBOT") @@ -60,6 +59,7 @@ function API.update(dt) sendErrorMessage("Arguments must be a table", "BALATROBOT") API.send_response({ error = "Arguments must be a table: " .. type(args) }) else + sendDebugMessage(data.name .. "(" .. json.encode(args) .. ")", "BALATROBOT") func(args) end end From 0acf22f99e9966ac08b24030fb66cd3c3e983d5b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 22:18:06 +0200 Subject: [PATCH 25/60] feat(api): game over and no discard left edge cases --- src/lua/api.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index c5337f3..1ec1a59 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -215,6 +215,12 @@ API.functions["skip_or_select_blind"] = function(args) end API.functions["play_hand_or_discard"] = function(args) + if args.action == "discard" and G.GAME.current_round.discards_left == 0 then + sendErrorMessage("No discards left to perform discard", "BALATROBOT") + API.send_response({ error = "No discards left to perform discard", state = G.STATE }) + return + end + -- adjust from 0-based to 1-based indexing for i, card_index in ipairs(args.cards) do args.cards[i] = card_index + 1 @@ -256,13 +262,12 @@ API.functions["play_hand_or_discard"] = function(args) -- round still going if G.buttons and G.STATE == G.STATES.SELECTING_HAND then return true - -- round won and entering cash out state (ROUND_EVAL state) elseif G.STATE == G.STATES.ROUND_EVAL then return true - - -- TODO: round lost (back to MAIN MENU) - -- TODO: round lost ("you lost" banner) + -- game over state + elseif G.STATE == G.STATES.GAME_OVER then + return true end end return false From 61775668d10a9457f42f8f798e068810a3ba7ee8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 22:18:43 +0200 Subject: [PATCH 26/60] test(api): add test for game over and no discards left --- tests/test_api_functions.py | 44 ++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 7c503fc..50a2b8e 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -299,18 +299,27 @@ def test_play_hand( assert len(set(final_card_keys) - set(init_card_keys)) == expected_new_cards assert set(final_card_keys) & set(played_hand_keys) == set() - def test_play_hand_winning( - self, udp_client: socket.socket, setup_and_teardown: dict - ) -> None: + def test_play_hand_winning(self, udp_client: socket.socket) -> None: """Test playing a winning hand (four of a kind)""" - _ = setup_and_teardown play_hand_args = {"action": "play_hand", "cards": [0, 1, 2, 3]} game_state = send_and_receive_api_message( udp_client, "play_hand_or_discard", play_hand_args ) assert game_state["state"] == State.ROUND_EVAL.value - def test_play_hand_invalid_cards(self, udp_client: socket.socket) -> None: + def test_play_hands_losing(self, udp_client: socket.socket) -> None: + """Test playing a series of losing hands and reach Main menu again.""" + for _ in range(4): + game_state = send_and_receive_api_message( + udp_client, + "play_hand_or_discard", + {"action": "play_hand", "cards": [0]}, + ) + assert game_state["state"] == State.GAME_OVER.value + + def test_play_hand_or_discard_invalid_cards( + self, udp_client: socket.socket + ) -> None: """Test playing a hand with invalid card indices returns error.""" play_hand_args = {"action": "play_hand", "cards": [10, 11, 12, 13, 14]} response = send_and_receive_api_message( @@ -376,3 +385,28 @@ def test_discard( ) assert len(set(final_card_keys) - set(init_card_keys)) == expected_new_cards assert set(final_card_keys) & set(discarded_hand_keys) == set() + + def test_try_to_discard_when_no_discards_left( + self, udp_client: socket.socket + ) -> None: + """Test trying to discard when no discards are left.""" + for _ in range(4): + game_state = send_and_receive_api_message( + udp_client, + "play_hand_or_discard", + {"action": "discard", "cards": [0]}, + ) + assert game_state["state"] == State.SELECTING_HAND.value + assert game_state["game"]["hands_played"] == 0 + assert game_state["game"]["current_round"]["discards_left"] == 0 + + response = send_and_receive_api_message( + udp_client, + "play_hand_or_discard", + {"action": "discard", "cards": [0]}, + ) + + # Should receive error response for no discards left + assert isinstance(response, dict) + assert "error" in response + assert "No discards left" in response["error"] From 26c99f7ce07bf9e930134dd2a61f8668029ae479 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 9 Jul 2025 22:24:56 +0200 Subject: [PATCH 27/60] chore: remove unused import --- src/balatrobot/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/balatrobot/utils.py b/src/balatrobot/utils.py index 1d20172..e4c66e7 100644 --- a/src/balatrobot/utils.py +++ b/src/balatrobot/utils.py @@ -6,7 +6,6 @@ import logging import sys from pathlib import Path -from typing import Any def setup_logging( From 988fc4a271b36eff61518d0dfdbc22dd877fd5f9 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 19:28:34 +0200 Subject: [PATCH 28/60] chore: add symbolic link for starting balatro to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e40e179..e666369 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ gamestate_cache src/lua_old balatrobot_old.lua scripts +balatro.sh From 517f314813d7eef669d12d4fa4f776c3251ffdcc Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 19:29:32 +0200 Subject: [PATCH 29/60] feat(api): add cashout API function --- src/lua/api.lua | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lua/api.lua b/src/lua/api.lua index 1ec1a59..fc4b897 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -279,6 +279,27 @@ API.functions["play_hand_or_discard"] = function(args) } end +API.functions["cash_out"] = function(args) + -- Validate current game state is appropriate for cash out + if G.STATE ~= G.STATES.ROUND_EVAL then + sendErrorMessage("Cannot cash out when not in shop. Current state: " .. tostring(G.STATE), "BALATROBOT") + API.send_response({ error = "Cannot cash out when not in shop", state = G.STATE }) + return + end + + G.FUNCS.cash_out({ config = {} }) + API.pending_requests["cash_out"] = { + condition = function() + return G.STATE == G.STATES.SHOP and #G.E_MANAGER.queues.base < 3 and G.STATE_COMPLETE + end, + action = function(args) + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } +end + API.functions["select_booster_action"] = function(args) -- TODO: implement end From d1fa001aa7859dea30cde91e5dc0e9c327962325 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 19:30:02 +0200 Subject: [PATCH 30/60] test(api): add tests for cash_out API function --- tests/test_api_functions.py | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 50a2b8e..74fa8b8 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -410,3 +410,58 @@ def test_try_to_discard_when_no_discards_left( assert isinstance(response, dict) assert "error" in response assert "No discards left" in response["error"] + + +class TestCashOut: + """Tests for the cash_out API endpoint.""" + + @pytest.fixture(autouse=True) + def setup_and_teardown( + self, udp_client: socket.socket + ) -> Generator[None, None, None]: + """Set up and tear down each test method.""" + # Start a run + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "OOOO155", # four of a kind in first hand + } + send_and_receive_api_message(udp_client, "start_run", start_run_args) + + # Select blind + send_and_receive_api_message( + udp_client, "skip_or_select_blind", {"action": "select"} + ) + + # Play a winning hand (four of a kind) to reach shop + game_state = send_and_receive_api_message( + udp_client, + "play_hand_or_discard", + {"action": "play_hand", "cards": [0, 1, 2, 3]}, + ) + assert game_state["state"] == State.ROUND_EVAL.value + yield + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + def test_cash_out_success(self, udp_client: socket.socket) -> None: + """Test successful cash out returns to shop state.""" + # Cash out should transition to shop state + game_state = send_and_receive_api_message(udp_client, "cash_out", {}) + + # Verify we're in shop state after cash out + assert game_state["state"] == State.SHOP.value + + def test_cash_out_invalid_state_error(self, udp_client: socket.socket) -> None: + """Test cash out returns error when not in shop state.""" + # Go to menu first to ensure we're not in shop state + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + # Try to cash out when not in shop - should return error + response = send_and_receive_api_message(udp_client, "cash_out", {}) + + # Verify error response + assert isinstance(response, dict) + assert "error" in response + assert "Cannot cash out when not in shop" in response["error"] + assert "state" in response From 8a07733a31fdb69fb16804239c8f74af2926141a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 20:26:10 +0200 Subject: [PATCH 31/60] docs: add CLAUDE.md with development guidelines and commands Co-Authored-By: Claude --- CLAUDE.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0983c12 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +This project requires that Balatro Game is running in the background. To run the game use the following command: + +```bash +# Basic command (may hang on crash) +./balatro.sh + +# Recommended: Run with logging and startup wait +./balatro.sh > balatro_game.log 2>&1 & sleep 10 && echo "Balatro started and ready" +``` + +### Game Monitoring Commands + +```bash +# Check if game is running +ps aux | grep run_lovely_macos + +# View game logs +tail -f balatro_game.log + +# View monitor logs +tail -f balatro_monitor.log + +# Kill game process +pkill -f run_lovely_macos +``` + +## Development Commands + +### Linting and Type Checking + +```bash +# Run ruff linter and formatter +ruff check . +ruff format . + +# Run type checker +basedpyright +``` + +### Testing + +**IMPORTANT**: Tests require Balatro to be running in the background. Always start the game before running tests. + +```bash +# Run all tests (requires Balatro to be running) +pytest + +# Run specific test file +pytest tests/test_api_functions.py + +# Run tests with verbose output +pytest -v +``` + +#### Test Prerequisites and Workflow + +1. **Always start Balatro first**: + ```bash + # Check if game is running + ps aux | grep run_lovely_macos + +# Start if not running + ./balatro.sh > balatro_game.log 2>&1 & sleep 10 && echo "Balatro started and ready" + ``` + +2. **Monitor game startup**: + ```bash + # Check logs for successful mod loading + tail -f balatro_game.log + + # Look for these success indicators: + # - "BalatrobotAPI initialized" + # - "BalatroBot loaded - version X.X.X" + # - "UDP socket created on port 12346" + ``` + +3. **Common startup issues and fixes**: + - **Syntax errors in balatrobot.lua**: Check for corrupted text like `asldkfjalksdjlocal` that should be `local` + - **Game crashes on mod load**: Review full log for Lua stack traces + - **Steam connection warnings**: Can be ignored - game works without Steam in development + - **JSON metadata errors**: Normal for development files (.vscode, .luarc.json) - can be ignored + +4. **Test execution**: + - **Test suite**: 31 tests covering API functions and UDP communication + - **Execution time**: ~56 seconds (includes game state transitions) + - **Coverage**: API function calls, socket communication, error handling, edge cases + +5. **Troubleshooting test failures**: + - **Connection timeouts**: Ensure UDP port 12346 is available + - **Game state errors**: Check if game is responsive and not crashed + - **Invalid responses**: Verify mod loaded correctly by checking logs + +### Documentation + +```bash +# Serve documentation locally +mkdocs serve + +# Build documentation +mkdocs build +``` + +### Bot Development + +```bash +# Run the example bot +python bots/example.py + +# Run bot with different log levels +python bots/example.py --log DEBUG +python bots/example.py --log INFO +``` + +## Architecture Overview + +BalatroBot is a Python framework for developing automated bots to play the card game Balatro. The architecture consists of three main layers: + +### 1. Communication Layer (UDP Protocol) + +- **Lua API** (`src/lua/api.lua`): Game-side mod that handles socket communication +- **UDP Socket Communication**: Real-time bidirectional communication between game and bot +- **Protocol**: Bot sends "HELLO" → Game responds with JSON state → Bot sends action strings + +### 2. Python Framework Layer (`src/balatrobot/`) + +**NOTE**: This is the old implementation that is being heavily refactored without backwards compatibility. +It will be drastically simplified in the future. For the moment I'm just focusing on the Lua API (`src/lua/api.lua`). +I keep the old code around for reference. + +- **Bot Base Class** (`base.py`): Abstract base class defining the bot interface +- **ActionSchema**: TypedDict defining structured action format with `action` (enum) and `args` (list) +- **Enums** (`enums.py`): Game state enums (Actions, Decks, Stakes, State) +- **Socket Management**: Automatic reconnection, timeout handling, JSON parsing + +## Development Standards + +### Python Code Style (from `.cursor/rules/`) + +- Use modern Python 3.12+ syntax with built-in collection types +- Type annotations with pipe operator for unions: `str | int | None` +- Use `type` statement for type aliases +- Google-style docstrings without type information (since type annotations are present) +- Modern generic class syntax: `class Container[T]:` + +## Project Structure Context + +- **Dual Implementation**: Both Python framework and Lua game mod +- **UDP Communication**: Port 12346 for real-time game interaction +- **MkDocs Documentation**: Comprehensive guides with Material theme +- **Pytest Testing**: UDP socket testing with fixtures +- **Development Tools**: Ruff, basedpyright, modern Python tooling + +### Testing Best Practices + +- **Always check that Balatro is running before running tests** +- After starting Balatro, check the log to confirm successful startup From 5f6ce8453fe22217c897523b315de85aadfe42d3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 21:54:17 +0200 Subject: [PATCH 32/60] ci: add claude code hooks for formatters --- .claude/settings.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c92cfc4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "stylua src/lua" + }, + { + "type": "command", + "command": "ruff format -s src/balatrobot" + } + ] + } + ] + } +} From 65c460502a6e7832fb94b25ac20a380720d2789c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 22:29:32 +0200 Subject: [PATCH 33/60] ci: add stylua formatter to code_quality workflow --- .github/workflows/code_quality.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 4fd65c5..0c7d70c 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -49,3 +49,14 @@ jobs: run: | source .venv/bin/activate basedpyright + stylua: + name: StyLua + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check Lua formatting + uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v4.1.0 + args: --check src/lua From 5e1723fe0ac578019d4ad2b1dd6e6de5564b26d3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 22:31:56 +0200 Subject: [PATCH 34/60] docs: update CLAUDE.md with test prerequisites and workflow --- CLAUDE.md | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0983c12..1948e7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,15 +59,17 @@ pytest -v #### Test Prerequisites and Workflow 1. **Always start Balatro first**: + ```bash # Check if game is running ps aux | grep run_lovely_macos -# Start if not running + # Start if not running ./balatro.sh > balatro_game.log 2>&1 & sleep 10 && echo "Balatro started and ready" ``` 2. **Monitor game startup**: + ```bash # Check logs for successful mod loading tail -f balatro_game.log @@ -79,14 +81,13 @@ pytest -v ``` 3. **Common startup issues and fixes**: - - **Syntax errors in balatrobot.lua**: Check for corrupted text like `asldkfjalksdjlocal` that should be `local` - **Game crashes on mod load**: Review full log for Lua stack traces - **Steam connection warnings**: Can be ignored - game works without Steam in development - **JSON metadata errors**: Normal for development files (.vscode, .luarc.json) - can be ignored 4. **Test execution**: - - **Test suite**: 31 tests covering API functions and UDP communication - - **Execution time**: ~56 seconds (includes game state transitions) + - **Test suite**: 32 tests covering API functions and UDP communication + - **Execution time**: ~60 seconds (includes game state transitions) - **Coverage**: API function calls, socket communication, error handling, edge cases 5. **Troubleshooting test failures**: @@ -104,17 +105,6 @@ mkdocs serve mkdocs build ``` -### Bot Development - -```bash -# Run the example bot -python bots/example.py - -# Run bot with different log levels -python bots/example.py --log DEBUG -python bots/example.py --log INFO -``` - ## Architecture Overview BalatroBot is a Python framework for developing automated bots to play the card game Balatro. The architecture consists of three main layers: From 284b3611b642fa553e212d54919a28bd42a4ecef Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 22:33:50 +0200 Subject: [PATCH 35/60] ci: use latest version of stylua action --- .github/workflows/code_quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 0c7d70c..14cacca 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -58,5 +58,5 @@ jobs: uses: JohnnyMorganz/stylua-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - version: v4.1.0 + version: latest args: --check src/lua From 3039d6368f81276ab326884c649a0471f5e88317 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 22:45:00 +0200 Subject: [PATCH 36/60] refactor: remove unused arguments from functions --- src/lua/api.lua | 43 ++++++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index fc4b897..0c9eef2 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -12,7 +12,7 @@ API.last_client_port = nil -- Update Loop -------------------------------------------------------------------------------- -function API.update(dt) +function API.update(_) -- Create socket if it doesn't exist if not API.socket then API.socket = socket.udp() @@ -24,8 +24,8 @@ function API.update(dt) -- Process pending requests for key, request in pairs(API.pending_requests) do - if request.condition(request.args) then - request.action(request.args) + if request.condition() then + request.action() API.pending_requests[key] = nil end end @@ -77,7 +77,7 @@ end function API.init() -- Hook into the game's update loop local original_update = love.update - love.update = function(dt) + love.update = function(_) original_update(BALATRO_BOT_CONFIG.dt) API.update(BALATRO_BOT_CONFIG.dt) end @@ -97,12 +97,12 @@ end -- API Functions -------------------------------------------------------------------------------- -API.functions["get_game_state"] = function(args) +API.functions["get_game_state"] = function(_) local game_state = utils.get_game_state() API.send_response(game_state) end -API.functions["go_to_menu"] = function(args) +API.functions["go_to_menu"] = function(_) if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then sendDebugMessage("go_to_menu called but already in menu", "BALATROBOT") local game_state = utils.get_game_state() @@ -115,11 +115,10 @@ API.functions["go_to_menu"] = function(args) condition = function() return G.STATE == G.STATES.MENU and G.MAIN_MENU_UI end, - action = function(args) + action = function() local game_state = utils.get_game_state() API.send_response(game_state) end, - args = args, } end @@ -166,7 +165,7 @@ API.functions["start_run"] = function(args) condition = function() return G.STATE == G.STATES.BLIND_SELECT and G.GAME.blind_on_deck end, - action = function(args) + action = function() local game_state = utils.get_game_state() API.send_response(game_state) end, @@ -183,7 +182,7 @@ API.functions["skip_or_select_blind"] = function(args) condition = function() return G.GAME and G.GAME.facing_blind and G.STATE == G.STATES.SELECTING_HAND end, - action = function(args) + action = function() local game_state = utils.get_game_state() API.send_response(game_state) end, @@ -201,11 +200,10 @@ API.functions["skip_or_select_blind"] = function(args) } return prev_state[current_blind] == "Skipped" end, - action = function(args) + action = function() local game_state = utils.get_game_state() API.send_response(game_state) end, - args = args, } else sendErrorMessage("Invalid action arg for skip_or_select_blind: " .. args.action, "BALATROBOT") @@ -272,14 +270,14 @@ API.functions["play_hand_or_discard"] = function(args) end return false end, - action = function(args) + action = function() local game_state = utils.get_game_state() API.send_response(game_state) end, } end -API.functions["cash_out"] = function(args) +API.functions["cash_out"] = function(_) -- Validate current game state is appropriate for cash out if G.STATE ~= G.STATES.ROUND_EVAL then sendErrorMessage("Cannot cash out when not in shop. Current state: " .. tostring(G.STATE), "BALATROBOT") @@ -292,39 +290,38 @@ API.functions["cash_out"] = function(args) condition = function() return G.STATE == G.STATES.SHOP and #G.E_MANAGER.queues.base < 3 and G.STATE_COMPLETE end, - action = function(args) + action = function() local game_state = utils.get_game_state() API.send_response(game_state) end, - args = args, } end -API.functions["select_booster_action"] = function(args) +API.functions["select_booster_action"] = function(_) -- TODO: implement end -API.functions["select_shop_action"] = function(args) +API.functions["select_shop_action"] = function(_) -- TODO: implement end -API.functions["rearrange_hand"] = function(args) +API.functions["rearrange_hand"] = function(_) -- TODO: implement end -API.functions["rearrange_consumables"] = function(args) +API.functions["rearrange_consumables"] = function(_) -- TODO: implement end -API.functions["rearrange_jokers"] = function(args) +API.functions["rearrange_jokers"] = function(_) -- TODO: implement end -API.functions["use_or_sell_consumables"] = function(args) +API.functions["use_or_sell_consumables"] = function(_) -- TODO: implement end -API.functions["sell_jokers"] = function(args) +API.functions["sell_jokers"] = function(_) -- TODO: implement end From 20f82ffc3fd5c5ac2dcc525bd694e89fe8093e4c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 23:25:50 +0200 Subject: [PATCH 37/60] refactor(tests): use helper function to assert error responses --- tests/conftest.py | 14 ++++++++++++- tests/test_api_functions.py | 41 ++++++++++++++++--------------------- tests/test_connection.py | 18 +++++----------- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index be542ed..cc1224a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ # Connection settings HOST = "127.0.0.1" PORT: int = 12346 # default port for BalatroBot UDP API -TIMEOUT: float = 30.0 # timeout for socket operations in seconds +TIMEOUT: float = 10.0 # timeout for socket operations in seconds BUFFER_SIZE: int = 65536 # 64KB buffer for UDP messages @@ -68,3 +68,15 @@ def send_and_receive_api_message( send_api_message(sock, name, arguments) game_state = receive_api_message(sock) return game_state + + +def assert_error_response(response, expected_error_text, expected_context_keys=None): + """Helper function to assert error response format and content.""" + assert isinstance(response, dict) + assert "error" in response + assert "state" in response + assert expected_error_text in response["error"] + if expected_context_keys: + assert "context" in response + for key in expected_context_keys: + assert key in response["context"] diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 74fa8b8..66aaba1 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -4,7 +4,7 @@ from typing import Generator import pytest -from conftest import send_and_receive_api_message +from conftest import send_and_receive_api_message, assert_error_response from balatrobot.enums import State @@ -127,9 +127,7 @@ def test_start_run_missing_required_args(self, udp_client: socket.socket) -> Non response = send_and_receive_api_message( udp_client, "start_run", incomplete_args ) - assert isinstance(response, dict) - assert "error" in response - assert "Invalid deck arg" in response["error"] + assert_error_response(response, "Invalid deck arg for start_run") def test_start_run_invalid_deck(self, udp_client: socket.socket) -> None: """Test start_run with invalid deck name.""" @@ -141,9 +139,7 @@ def test_start_run_invalid_deck(self, udp_client: socket.socket) -> None: } # Should receive error response response = send_and_receive_api_message(udp_client, "start_run", invalid_args) - assert isinstance(response, dict) - assert "error" in response - assert "Invalid deck arg" in response["error"] + assert_error_response(response, "Invalid deck arg for start_run", ["deck"]) class TestGoToMenu: @@ -231,9 +227,9 @@ def test_invalid_blind_action(self, udp_client: socket.socket) -> None: ) # Verify error response - assert isinstance(error_response, dict) - assert "error" in error_response - assert "Invalid action arg" in error_response["error"] + assert_error_response( + error_response, "Invalid action arg for skip_or_select_blind", ["action"] + ) class TestPlayHandOrDiscard: @@ -327,9 +323,9 @@ def test_play_hand_or_discard_invalid_cards( ) # Should receive error response for invalid card index - assert isinstance(response, dict) - assert "error" in response - assert "Invalid card index" in response["error"] + assert_error_response( + response, "Invalid card index", ["card_index", "hand_size"] + ) def test_play_hand_invalid_action(self, udp_client: socket.socket) -> None: """Test playing a hand with invalid action returns error.""" @@ -339,9 +335,9 @@ def test_play_hand_invalid_action(self, udp_client: socket.socket) -> None: ) # Should receive error response for invalid action - assert isinstance(response, dict) - assert "error" in response - assert "Invalid action arg" in response["error"] + assert_error_response( + response, "Invalid action arg for play_hand_or_discard", ["action"] + ) @pytest.mark.parametrize( "cards,expected_new_cards", @@ -407,9 +403,9 @@ def test_try_to_discard_when_no_discards_left( ) # Should receive error response for no discards left - assert isinstance(response, dict) - assert "error" in response - assert "No discards left" in response["error"] + assert_error_response( + response, "No discards left to perform discard", ["discards_left"] + ) class TestCashOut: @@ -461,7 +457,6 @@ def test_cash_out_invalid_state_error(self, udp_client: socket.socket) -> None: response = send_and_receive_api_message(udp_client, "cash_out", {}) # Verify error response - assert isinstance(response, dict) - assert "error" in response - assert "Cannot cash out when not in shop" in response["error"] - assert "state" in response + assert_error_response( + response, "Cannot cash out when not in shop", ["current_state"] + ) diff --git a/tests/test_connection.py b/tests/test_connection.py index d201c23..9ef1a7d 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -4,7 +4,7 @@ import socket import pytest -from conftest import BUFFER_SIZE, HOST, PORT, send_api_message +from conftest import BUFFER_SIZE, HOST, PORT, send_api_message, assert_error_response def test_basic_connection(udp_client: socket.socket) -> None: @@ -54,9 +54,7 @@ def test_invalid_json_message(udp_client: socket.socket) -> None: data, _ = udp_client.recvfrom(BUFFER_SIZE) response = data.decode().strip() error_response = json.loads(response) - assert isinstance(error_response, dict) - assert "error" in error_response - assert "Invalid JSON" in error_response["error"] + assert_error_response(error_response, "Invalid JSON") # Verify server is still responsive send_api_message(udp_client, "get_game_state", {}) @@ -77,9 +75,7 @@ def test_missing_name_field(udp_client: socket.socket) -> None: data, _ = udp_client.recvfrom(BUFFER_SIZE) response = data.decode().strip() error_response = json.loads(response) - assert isinstance(error_response, dict) - assert "error" in error_response - assert "Message must contain a name" in error_response["error"] + assert_error_response(error_response, "Message must contain a name") # Verify server is still responsive send_api_message(udp_client, "get_game_state", {}) @@ -100,9 +96,7 @@ def test_missing_arguments_field(udp_client: socket.socket) -> None: data, _ = udp_client.recvfrom(BUFFER_SIZE) response = data.decode().strip() error_response = json.loads(response) - assert isinstance(error_response, dict) - assert "error" in error_response - assert "Message must contain arguments" in error_response["error"] + assert_error_response(error_response, "Message must contain arguments") # Verify server is still responsive send_api_message(udp_client, "get_game_state", {}) @@ -123,9 +117,7 @@ def test_unknown_message(udp_client: socket.socket) -> None: data, _ = udp_client.recvfrom(BUFFER_SIZE) response = data.decode().strip() error_response = json.loads(response) - assert isinstance(error_response, dict) - assert "error" in error_response - assert "Unknown function name" in error_response["error"] + assert_error_response(error_response, "Unknown function name", ["function_name"]) # Verify server is still responsive send_api_message(udp_client, "get_game_state", {}) From a5a5d8e16193a42354e4b8fbfa5919fa7742c40e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 23:26:48 +0200 Subject: [PATCH 38/60] docs: remove redundant commands and refine existing ones --- CLAUDE.md | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1948e7b..73267a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,32 +2,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -This project requires that Balatro Game is running in the background. To run the game use the following command: - -```bash -# Basic command (may hang on crash) -./balatro.sh - -# Recommended: Run with logging and startup wait -./balatro.sh > balatro_game.log 2>&1 & sleep 10 && echo "Balatro started and ready" -``` - -### Game Monitoring Commands - -```bash -# Check if game is running -ps aux | grep run_lovely_macos - -# View game logs -tail -f balatro_game.log - -# View monitor logs -tail -f balatro_monitor.log - -# Kill game process -pkill -f run_lovely_macos -``` - ## Development Commands ### Linting and Type Checking @@ -65,14 +39,14 @@ pytest -v ps aux | grep run_lovely_macos # Start if not running - ./balatro.sh > balatro_game.log 2>&1 & sleep 10 && echo "Balatro started and ready" + ./balatro.sh > balatrobot.log 2>&1 & sleep 10 && echo "Balatro started and ready" ``` 2. **Monitor game startup**: ```bash # Check logs for successful mod loading - tail -f balatro_game.log + tail -n 100 balatrobot.log # Look for these success indicators: # - "BalatrobotAPI initialized" @@ -86,8 +60,8 @@ pytest -v - **JSON metadata errors**: Normal for development files (.vscode, .luarc.json) - can be ignored 4. **Test execution**: - - **Test suite**: 32 tests covering API functions and UDP communication - - **Execution time**: ~60 seconds (includes game state transitions) + - **Test suite**: 33 tests covering API functions and UDP communication + - **Execution time**: ~70 seconds (includes game state transitions) - **Coverage**: API function calls, socket communication, error handling, edge cases 5. **Troubleshooting test failures**: @@ -147,4 +121,4 @@ I keep the old code around for reference. ### Testing Best Practices - **Always check that Balatro is running before running tests** -- After starting Balatro, check the log to confirm successful startup +- After starting Balatro, check the `balatrobot.log` to confirm successful startup From 9a9280f7cf039427a590f2186fd5a995f6847c00 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 23:27:37 +0200 Subject: [PATCH 39/60] refactor(api): use helper function for standard error responses --- src/lua/api.lua | 58 +++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index 0c9eef2..cdea3be 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -1,6 +1,11 @@ local socket = require("socket") local json = require("json") +-- Constants +local UDP_BUFFER_SIZE = 65536 +local SOCKET_TIMEOUT = 0 +local EVENT_QUEUE_THRESHOLD = 3 + API = {} API.socket = nil API.functions = {} @@ -16,7 +21,7 @@ function API.update(_) -- Create socket if it doesn't exist if not API.socket then API.socket = socket.udp() - API.socket:settimeout(0) + API.socket:settimeout(SOCKET_TIMEOUT) local port = BALATRO_BOT_CONFIG.port API.socket:setsockname("127.0.0.1", tonumber(port)) sendDebugMessage("UDP socket created on port " .. port, "BALATROBOT") @@ -31,7 +36,7 @@ function API.update(_) end -- Parse received data and run the appropriate function - local raw_data, client_ip, client_port = API.socket:receivefrom(65536) + local raw_data, client_ip, client_port = API.socket:receivefrom(UDP_BUFFER_SIZE) if raw_data and client_ip and client_port then -- Store the last client connection API.last_client_ip = client_ip @@ -39,25 +44,20 @@ function API.update(_) local ok, data = pcall(json.decode, raw_data) if not ok then - sendErrorMessage("Invalid JSON", "BALATROBOT") - API.send_response({ error = "Invalid JSON" }) + API.send_error_response("Invalid JSON") return end if data.name == nil then - sendErrorMessage("Message must contain a name", "BALATROBOT") - API.send_response({ error = "Message must contain a name" }) + API.send_error_response("Message must contain a name") elseif data.arguments == nil then - sendErrorMessage("Message must contain arguments", "BALATROBOT") - API.send_response({ error = "Message must contain arguments" }) + API.send_error_response("Message must contain arguments") else local func = API.functions[data.name] local args = data.arguments if func == nil then - sendErrorMessage("Unknown function name: " .. data.name, "BALATROBOT") - API.send_response({ error = "Unknown function name: " .. data.name }) + API.send_error_response("Unknown function name", { function_name = data.name }) elseif type(args) ~= "table" then - sendErrorMessage("Arguments must be a table", "BALATROBOT") - API.send_response({ error = "Arguments must be a table: " .. type(args) }) + API.send_error_response("Arguments must be a table", { received_type = type(args) }) else sendDebugMessage(data.name .. "(" .. json.encode(args) .. ")", "BALATROBOT") func(args) @@ -74,6 +74,15 @@ function API.send_response(response) end end +function API.send_error_response(message, context) + sendErrorMessage(message, "BALATROBOT") + local response = { error = message, state = G.STATE } + if context then + response.context = context + end + API.send_response(response) +end + function API.init() -- Hook into the game's update loop local original_update = love.update @@ -140,8 +149,7 @@ API.functions["start_run"] = function(args) end end if not deck_found then - sendErrorMessage("Invalid deck arg for start_run: " .. tostring(args.deck), "BALATROBOT") - API.send_response({ error = "Invalid deck arg for start_run: " .. tostring(args.deck) }) + API.send_error_response("Invalid deck arg for start_run", { deck = args.deck }) return end @@ -206,16 +214,17 @@ API.functions["skip_or_select_blind"] = function(args) end, } else - sendErrorMessage("Invalid action arg for skip_or_select_blind: " .. args.action, "BALATROBOT") - API.send_response({ error = "Invalid action arg for skip_or_select_blind: " .. args.action }) + API.send_error_response("Invalid action arg for skip_or_select_blind", { action = args.action }) return end end API.functions["play_hand_or_discard"] = function(args) if args.action == "discard" and G.GAME.current_round.discards_left == 0 then - sendErrorMessage("No discards left to perform discard", "BALATROBOT") - API.send_response({ error = "No discards left to perform discard", state = G.STATE }) + API.send_error_response( + "No discards left to perform discard", + { discards_left = G.GAME.current_round.discards_left } + ) return end @@ -227,8 +236,7 @@ API.functions["play_hand_or_discard"] = function(args) -- Check that all cards are selectable for _, card_index in ipairs(args.cards) do if not G.hand.cards[card_index] then - sendErrorMessage("Invalid card index: " .. tostring(card_index), "BALATROBOT") - API.send_response({ error = "Invalid card index: " .. tostring(card_index) }) + API.send_error_response("Invalid card index", { card_index = card_index, hand_size = #G.hand.cards }) return end end @@ -247,8 +255,7 @@ API.functions["play_hand_or_discard"] = function(args) local discard_button = UIBox:get_UIE_by_ID("discard_button", G.buttons.UIRoot) G.FUNCS["discard_cards_from_highlighted"](discard_button) else - sendErrorMessage("Invalid action arg for play_hand_or_discard: " .. args.action, "BALATROBOT") - API.send_response({ error = "Invalid action arg for play_hand_or_discard: " .. args.action }) + API.send_error_response("Invalid action arg for play_hand_or_discard", { action = args.action }) return end @@ -256,7 +263,7 @@ API.functions["play_hand_or_discard"] = function(args) API.pending_requests["play_hand_or_discard"] = { condition = function() -- TODO: maybe remove brittle G.E_MANAGER check - if #G.E_MANAGER.queues.base < 3 and G.STATE_COMPLETE then + if #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE then -- round still going if G.buttons and G.STATE == G.STATES.SELECTING_HAND then return true @@ -280,15 +287,14 @@ end API.functions["cash_out"] = function(_) -- Validate current game state is appropriate for cash out if G.STATE ~= G.STATES.ROUND_EVAL then - sendErrorMessage("Cannot cash out when not in shop. Current state: " .. tostring(G.STATE), "BALATROBOT") - API.send_response({ error = "Cannot cash out when not in shop", state = G.STATE }) + API.send_error_response("Cannot cash out when not in shop", { current_state = G.STATE }) return end G.FUNCS.cash_out({ config = {} }) API.pending_requests["cash_out"] = { condition = function() - return G.STATE == G.STATES.SHOP and #G.E_MANAGER.queues.base < 3 and G.STATE_COMPLETE + return G.STATE == G.STATES.SHOP and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE end, action = function() local game_state = utils.get_game_state() From fdded2727d13bca8cdd2c3eb2bf710cf0f1103c7 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 23:53:02 +0200 Subject: [PATCH 40/60] feat(api): validate state in the usage of API functions --- src/lua/api.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lua/api.lua b/src/lua/api.lua index cdea3be..f2ba7c3 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -181,6 +181,12 @@ API.functions["start_run"] = function(args) end API.functions["skip_or_select_blind"] = function(args) + -- Validate current game state is appropriate for blind selection + if G.STATE ~= G.STATES.BLIND_SELECT then + API.send_error_response("Cannot skip or select blind when not in blind selection", { current_state = G.STATE }) + return + end + local current_blind = G.GAME.blind_on_deck local blind_obj = G.blind_select_opts[string.lower(current_blind)] if args.action == "select" then @@ -220,6 +226,12 @@ API.functions["skip_or_select_blind"] = function(args) end API.functions["play_hand_or_discard"] = function(args) + -- Validate current game state is appropriate for playing hand or discarding + if G.STATE ~= G.STATES.SELECTING_HAND then + API.send_error_response("Cannot play hand or discard when not selecting hand", { current_state = G.STATE }) + return + end + if args.action == "discard" and G.GAME.current_round.discards_left == 0 then API.send_error_response( "No discards left to perform discard", From 683ea0794ba7551f56207f59340d6946d3f9ac4f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 23:53:29 +0200 Subject: [PATCH 41/60] test(api): add test for validation error responses --- tests/test_api_functions.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 66aaba1..a758991 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -231,6 +231,21 @@ def test_invalid_blind_action(self, udp_client: socket.socket) -> None: error_response, "Invalid action arg for skip_or_select_blind", ["action"] ) + def test_skip_or_select_blind_invalid_state(self, udp_client: socket.socket) -> None: + """Test that skip_or_select_blind returns error when not in blind selection state.""" + # Go to menu to ensure we're not in blind selection state + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + # Try to select blind when not in blind selection state + error_response = send_and_receive_api_message( + udp_client, "skip_or_select_blind", {"action": "select"} + ) + + # Verify error response + assert_error_response( + error_response, "Cannot skip or select blind when not in blind selection", ["current_state"] + ) + class TestPlayHandOrDiscard: """Tests for the play_hand_or_discard API endpoint.""" @@ -407,6 +422,21 @@ def test_try_to_discard_when_no_discards_left( response, "No discards left to perform discard", ["discards_left"] ) + def test_play_hand_or_discard_invalid_state(self, udp_client: socket.socket) -> None: + """Test that play_hand_or_discard returns error when not in selecting hand state.""" + # Go to menu to ensure we're not in selecting hand state + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + # Try to play hand when not in selecting hand state + error_response = send_and_receive_api_message( + udp_client, "play_hand_or_discard", {"action": "play_hand", "cards": [0, 1, 2, 3, 4]} + ) + + # Verify error response + assert_error_response( + error_response, "Cannot play hand or discard when not selecting hand", ["current_state"] + ) + class TestCashOut: """Tests for the cash_out API endpoint.""" From fe64c88613726ed58df690b00361e6c3c67c2821 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 10 Jul 2025 23:55:14 +0200 Subject: [PATCH 42/60] docs: renamed log file and provide suggestion for Timeout errors --- CLAUDE.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73267a8..92f8d07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,14 +39,14 @@ pytest -v ps aux | grep run_lovely_macos # Start if not running - ./balatro.sh > balatrobot.log 2>&1 & sleep 10 && echo "Balatro started and ready" + ./balatro.sh > balatro.log 2>&1 & sleep 10 && echo "Balatro started and ready" ``` 2. **Monitor game startup**: ```bash # Check logs for successful mod loading - tail -n 100 balatrobot.log + tail -n 100 balatro.log # Look for these success indicators: # - "BalatrobotAPI initialized" @@ -60,14 +60,15 @@ pytest -v - **JSON metadata errors**: Normal for development files (.vscode, .luarc.json) - can be ignored 4. **Test execution**: - - **Test suite**: 33 tests covering API functions and UDP communication - - **Execution time**: ~70 seconds (includes game state transitions) + - **Test suite**: 35 tests covering API functions and UDP communication + - **Execution time**: ~80 seconds (includes game state transitions) - **Coverage**: API function calls, socket communication, error handling, edge cases 5. **Troubleshooting test failures**: - **Connection timeouts**: Ensure UDP port 12346 is available - **Game state errors**: Check if game is responsive and not crashed - **Invalid responses**: Verify mod loaded correctly by checking logs + - **If test/s fail for timeout the reasons is that Balatro crash because there was an error in the Balatro mod (i.e. @balatrobot.lua and @src/lua/ ). The error should be logged in the `balatro.log` file.** ### Documentation @@ -121,4 +122,4 @@ I keep the old code around for reference. ### Testing Best Practices - **Always check that Balatro is running before running tests** -- After starting Balatro, check the `balatrobot.log` to confirm successful startup +- After starting Balatro, check the `balatro.log` to confirm successful startup \ No newline at end of file From a8135fe01997349afe7feef89628dc6d283e7906 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 00:00:39 +0200 Subject: [PATCH 43/60] style(test): sort imports --- tests/test_api_functions.py | 22 ++++++++++++++++------ tests/test_connection.py | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index a758991..6976f9e 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -4,7 +4,7 @@ from typing import Generator import pytest -from conftest import send_and_receive_api_message, assert_error_response +from conftest import assert_error_response, send_and_receive_api_message from balatrobot.enums import State @@ -231,7 +231,9 @@ def test_invalid_blind_action(self, udp_client: socket.socket) -> None: error_response, "Invalid action arg for skip_or_select_blind", ["action"] ) - def test_skip_or_select_blind_invalid_state(self, udp_client: socket.socket) -> None: + def test_skip_or_select_blind_invalid_state( + self, udp_client: socket.socket + ) -> None: """Test that skip_or_select_blind returns error when not in blind selection state.""" # Go to menu to ensure we're not in blind selection state send_and_receive_api_message(udp_client, "go_to_menu", {}) @@ -243,7 +245,9 @@ def test_skip_or_select_blind_invalid_state(self, udp_client: socket.socket) -> # Verify error response assert_error_response( - error_response, "Cannot skip or select blind when not in blind selection", ["current_state"] + error_response, + "Cannot skip or select blind when not in blind selection", + ["current_state"], ) @@ -422,19 +426,25 @@ def test_try_to_discard_when_no_discards_left( response, "No discards left to perform discard", ["discards_left"] ) - def test_play_hand_or_discard_invalid_state(self, udp_client: socket.socket) -> None: + def test_play_hand_or_discard_invalid_state( + self, udp_client: socket.socket + ) -> None: """Test that play_hand_or_discard returns error when not in selecting hand state.""" # Go to menu to ensure we're not in selecting hand state send_and_receive_api_message(udp_client, "go_to_menu", {}) # Try to play hand when not in selecting hand state error_response = send_and_receive_api_message( - udp_client, "play_hand_or_discard", {"action": "play_hand", "cards": [0, 1, 2, 3, 4]} + udp_client, + "play_hand_or_discard", + {"action": "play_hand", "cards": [0, 1, 2, 3, 4]}, ) # Verify error response assert_error_response( - error_response, "Cannot play hand or discard when not selecting hand", ["current_state"] + error_response, + "Cannot play hand or discard when not selecting hand", + ["current_state"], ) diff --git a/tests/test_connection.py b/tests/test_connection.py index 9ef1a7d..8a3b467 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -4,7 +4,7 @@ import socket import pytest -from conftest import BUFFER_SIZE, HOST, PORT, send_api_message, assert_error_response +from conftest import BUFFER_SIZE, HOST, PORT, assert_error_response, send_api_message def test_basic_connection(udp_client: socket.socket) -> None: From 4ab569a78f4607ed3c162d44ad11d1fa5f56bf8c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 00:54:56 +0200 Subject: [PATCH 44/60] feat(dev): add commit command with conventional commits spec Add structured commit message generation command with: - Conventional commit types and project-specific scopes - Workflow steps for comprehensive change analysis - User approval flow before committing - Co-authoring support for Claude-assisted development Co-Authored-By: Claude --- .claude/commands/commit.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .claude/commands/commit.md diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..18d8554 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,35 @@ +Generate a conventional commit message for the current staged changes. + +Analyze the git diff of staged files and create a commit message following conventional commits specification: + +**Format:** `(): ` + +**Types:** +- feat: new feature +- fix: bug fix +- docs: documentation +- style: formatting, missing semicolons, etc. +- refactor: code change that neither fixes a bug nor adds a feature +- test: adding or correcting tests +- chore: maintenance tasks +- ci: continuous integration changes +- revert: reverts a previous commit + +**Scopes:** +- api: Lua API and Python API communication +- bot: Python bot framework and base classes +- examples: Example bots and usage samples +- dev: Development tools and environment + +**Workflow:** +1. Run `git status` to see overall repository state +2. Run `git diff --staged` to analyze the actual changes +3. Run `git diff --stat --staged` for summary of changed files +4. Run `git log --oneline -10` to review recent commit patterns +5. Choose appropriate type and scope based on changes +6. Write concise description (50 chars max for first line) +7. Include body if changes are complex +8. Add breaking change footer if applicable +9. If this is the final commit after extensive Claude assistance with code creation/modification/deletion, add `Co-Authored-By: Claude ` at the end of the commit body +10. Present the generated commit message to the user for approval +11. If user approves, commit the staged changes with the generated message From 1aaa7b7b22da35c8568270f3b8fa5e501e598ff1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 08:11:15 +0200 Subject: [PATCH 45/60] docs(dev): refine commit command workflow instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified workflow steps by removing user approval step and duplicate step numbering. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/commands/commit.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 18d8554..cde48f0 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -22,7 +22,7 @@ Analyze the git diff of staged files and create a commit message following conve - dev: Development tools and environment **Workflow:** -1. Run `git status` to see overall repository state +1. Run `git status` to see overall repository state. If there are are no staged changes, exit. 2. Run `git diff --staged` to analyze the actual changes 3. Run `git diff --stat --staged` for summary of changed files 4. Run `git log --oneline -10` to review recent commit patterns @@ -31,5 +31,4 @@ Analyze the git diff of staged files and create a commit message following conve 7. Include body if changes are complex 8. Add breaking change footer if applicable 9. If this is the final commit after extensive Claude assistance with code creation/modification/deletion, add `Co-Authored-By: Claude ` at the end of the commit body -10. Present the generated commit message to the user for approval -11. If user approves, commit the staged changes with the generated message +10. Commit the staged changes with the generated message From b61507fbc883c8a4946ea47fb080859b4f447307 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 08:42:36 +0200 Subject: [PATCH 46/60] docs(dev): clarify commit command formatting guidelines Add notes to explicitly exclude emojis and Claude Code generation text from commit messages to maintain clean conventional commit format. --- .claude/commands/commit.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index cde48f0..1679da5 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -32,3 +32,7 @@ Analyze the git diff of staged files and create a commit message following conve 8. Add breaking change footer if applicable 9. If this is the final commit after extensive Claude assistance with code creation/modification/deletion, add `Co-Authored-By: Claude ` at the end of the commit body 10. Commit the staged changes with the generated message + +**Notes** +- Do not include emojis in the commit message. +- Do not include `🤖 Generated with [Claude Code](https://claude.ai/code)` in the commit message. From bd4f5356fd1813bdf433b4e485158d35aaab8fb4 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 08:43:48 +0200 Subject: [PATCH 47/60] feat(dev): add test command and improve process detection Add new test command documentation and update CLAUDE.md to use improved process detection that checks for both Balatro.app and balatro.sh instead of the deprecated run_lovely_macos pattern. --- .claude/commands/test.md | 3 +++ CLAUDE.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .claude/commands/test.md diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000..48f744e --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,3 @@ +Run tests following the testing guidelines in CLAUDE.md. + +See the Testing section in CLAUDE.md for complete instructions on prerequisites, workflow, and troubleshooting. diff --git a/CLAUDE.md b/CLAUDE.md index 92f8d07..3c6a4c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ pytest -v ```bash # Check if game is running - ps aux | grep run_lovely_macos + ps aux | grep -E "(Balatro\.app|balatro\.sh)" | grep -v grep # Start if not running ./balatro.sh > balatro.log 2>&1 & sleep 10 && echo "Balatro started and ready" @@ -122,4 +122,4 @@ I keep the old code around for reference. ### Testing Best Practices - **Always check that Balatro is running before running tests** -- After starting Balatro, check the `balatro.log` to confirm successful startup \ No newline at end of file +- After starting Balatro, check the `balatro.log` to confirm successful startup From 3aab28321311bb1d26bfbbb7b29b38a3d1ceb8c1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 10:26:04 +0200 Subject: [PATCH 48/60] refactor(api): add comprehensive type definitions and documentation - Add complete type system in src/lua/types.lua with API, game state, and socket types - Add function documentation with type annotations to api.lua - Add function documentation with type annotations to utils.lua - Move type definitions from balatrobot.lua to dedicated types file - Improve code maintainability and IDE support for Lua development --- balatrobot.lua | 9 ---- src/lua/api.lua | 37 +++++++++++++- src/lua/types.lua | 121 ++++++++++++++++++++++++++++++++++++++++++++++ src/lua/utils.lua | 7 +++ 4 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 src/lua/types.lua diff --git a/balatrobot.lua b/balatrobot.lua index 1f7324b..4187e69 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -1,12 +1,3 @@ ----@meta balatrobot ----Main entry point for the BalatroBot mod - ----@class BalatrobotConfig ----@field port string Port for the bot to listen on ----@field dt number Tells the game that every update is dt seconds long. ----@field max_fps integer? Maximum frames per second ----@field vsync_enabled boolean Whether vertical sync is enabled - ---Global configuration for the BalatroBot mod ---@type BalatrobotConfig BALATRO_BOT_CONFIG = { diff --git a/src/lua/api.lua b/src/lua/api.lua index f2ba7c3..2f680b4 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -5,7 +5,6 @@ local json = require("json") local UDP_BUFFER_SIZE = 65536 local SOCKET_TIMEOUT = 0 local EVENT_QUEUE_THRESHOLD = 3 - API = {} API.socket = nil API.functions = {} @@ -17,13 +16,15 @@ API.last_client_port = nil -- Update Loop -------------------------------------------------------------------------------- +---Updates the API by processing UDP messages and pending requests +---@param _ number Delta time (not used) function API.update(_) -- Create socket if it doesn't exist if not API.socket then API.socket = socket.udp() API.socket:settimeout(SOCKET_TIMEOUT) local port = BALATRO_BOT_CONFIG.port - API.socket:setsockname("127.0.0.1", tonumber(port)) + API.socket:setsockname("127.0.0.1", tonumber(port) or 12346) sendDebugMessage("UDP socket created on port " .. port, "BALATROBOT") end @@ -68,12 +69,17 @@ function API.update(_) end end +---Sends a response back to the last connected client +---@param response table The response data to send function API.send_response(response) if API.last_client_ip and API.last_client_port then API.socket:sendto(json.encode(response), API.last_client_ip, API.last_client_port) end end +---Sends an error response to the client with optional context +---@param message string The error message +---@param context? table Optional additional context about the error function API.send_error_response(message, context) sendErrorMessage(message, "BALATROBOT") local response = { error = message, state = G.STATE } @@ -83,6 +89,7 @@ function API.send_error_response(message, context) API.send_response(response) end +---Initializes the API by hooking into the game's update loop and configuring settings function API.init() -- Hook into the game's update loop local original_update = love.update @@ -106,11 +113,15 @@ end -- API Functions -------------------------------------------------------------------------------- +---Gets the current game state +---@param _ table Arguments (not used) API.functions["get_game_state"] = function(_) local game_state = utils.get_game_state() API.send_response(game_state) end +---Navigates to the main menu +---@param _ table Arguments (not used) API.functions["go_to_menu"] = function(_) if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then sendDebugMessage("go_to_menu called but already in menu", "BALATROBOT") @@ -131,6 +142,8 @@ API.functions["go_to_menu"] = function(_) } end +---Starts a new game run with specified parameters +---@param args StartRunArgs The run configuration API.functions["start_run"] = function(args) -- Reset the game local play_button = G.MAIN_MENU_UI:get_UIE_by_ID("main_menu_play") @@ -180,6 +193,8 @@ API.functions["start_run"] = function(args) } end +---Skips or selects the current blind +---@param args BlindActionArgs The blind action to perform API.functions["skip_or_select_blind"] = function(args) -- Validate current game state is appropriate for blind selection if G.STATE ~= G.STATES.BLIND_SELECT then @@ -225,6 +240,8 @@ API.functions["skip_or_select_blind"] = function(args) end end +---Plays selected cards or discards them +---@param args HandActionArgs The hand action to perform API.functions["play_hand_or_discard"] = function(args) -- Validate current game state is appropriate for playing hand or discarding if G.STATE ~= G.STATES.SELECTING_HAND then @@ -296,6 +313,8 @@ API.functions["play_hand_or_discard"] = function(args) } end +---Cashes out from the current round to enter the shop +---@param _ table Arguments (not used) API.functions["cash_out"] = function(_) -- Validate current game state is appropriate for cash out if G.STATE ~= G.STATES.ROUND_EVAL then @@ -315,30 +334,44 @@ API.functions["cash_out"] = function(_) } end +---Selects an action for booster packs (TODO implement) +---@param _ table Arguments API.functions["select_booster_action"] = function(_) -- TODO: implement end +---Selects an action in the shop (TODO implement) +---@param _ table Arguments API.functions["select_shop_action"] = function(_) -- TODO: implement end +---Rearranges cards in hand (TODO implement) +---@param _ table Arguments API.functions["rearrange_hand"] = function(_) -- TODO: implement end +---Rearranges consumable cards (TODO implement) +---@param _ table Arguments API.functions["rearrange_consumables"] = function(_) -- TODO: implement end +---Rearranges joker cards (TODO implement) +---@param _ table Arguments API.functions["rearrange_jokers"] = function(_) -- TODO: implement end +---Uses or sells consumable cards (TODO implement) +---@param _ table Arguments API.functions["use_or_sell_consumables"] = function(_) -- TODO: implement end +---Sells joker cards (TODO implement) +---@param _ table Arguments API.functions["sell_jokers"] = function(_) -- TODO: implement end diff --git a/src/lua/types.lua b/src/lua/types.lua new file mode 100644 index 0000000..77180b2 --- /dev/null +++ b/src/lua/types.lua @@ -0,0 +1,121 @@ +---@meta balatrobot-types +---Type definitions for the BalatroBot Lua mod + +-- ============================================================================= +-- UDP Socket Types +-- ============================================================================= + +---@class UDPSocket +---@field settimeout fun(self: UDPSocket, timeout: number) +---@field setsockname fun(self: UDPSocket, address: string, port: number): boolean, string? +---@field receivefrom fun(self: UDPSocket, size: number): string?, string?, number? +---@field sendto fun(self: UDPSocket, data: string, address: string, port: number): number?, string? + +-- ============================================================================= +-- API Request Types (used in api.lua) +-- ============================================================================= + +---@class PendingRequest +---@field condition fun(): boolean Function that returns true when the request condition is met +---@field action fun() Function to execute when condition is met +---@field args? table Optional arguments passed to the request + +---@class APIRequest +---@field name string The name of the API function to call +---@field arguments table The arguments to pass to the function + +---@class ErrorResponse +---@field error string The error message +---@field state any The current game state +---@field context? table Optional additional context about the error + +---@class StartRunArgs +---@field deck string The deck name to use +---@field stake? number The stake level (optional) +---@field seed? string The seed for the run (optional) +---@field challenge? string The challenge name (optional) + +-- ============================================================================= +-- Game Action Argument Types (used in api.lua) +-- ============================================================================= + +---@class BlindActionArgs +---@field action "select" | "skip" The action to perform on the blind + +---@class HandActionArgs +---@field action "play_hand" | "discard" The action to perform +---@field cards number[] Array of card indices (0-based) + +-- ============================================================================= +-- Main API Module (defined in api.lua) +-- ============================================================================= + +---Main API module for handling UDP communication with bots +---@class API +---@field socket? UDPSocket UDP socket instance +---@field functions table Map of API function names to their implementations +---@field pending_requests table Map of pending async requests +---@field last_client_ip? string IP address of the last client that sent a message +---@field last_client_port? number Port of the last client that sent a message + +-- ============================================================================= +-- Game Entity Types (used in utils.lua for state extraction) +-- ============================================================================= + +---@class CardConfig +---@field name string The name of the card +---@field suit string The suit of the card +---@field value string The value/rank of the card +---@field card_key string Unique identifier for the card + +---@class Card +---@field label string The display label of the card +---@field config table Card configuration data + +---@class HandCard : Card +---@field config.card CardConfig Card-specific configuration + +---@class JokerCard : Card +---@field config.center table Center configuration for joker cards + +---@class GameRound +---@field discards_left number Number of discards remaining in the current round + +---@class GameState +---@field hands_played number Total hands played in the run +---@field skips number Number of skips used +---@field round number Current round number +---@field discount_percent number Shop discount percentage +---@field interest_cap number Maximum interest that can be earned +---@field inflation number Current inflation rate +---@field dollars number Current money amount +---@field max_jokers number Maximum number of jokers allowed +---@field bankrupt_at number Money threshold for bankruptcy +---@field chips number Current chip count +---@field blind_on_deck string Current blind type ("Small", "Large", "Boss") +---@field current_round GameRound Current round information + +---@class GameStateResponse +---@field state any Current game state enum value +---@field game? GameState Game information (null if not in game) +---@field hand? HandCard[] Array of cards in hand (null if not available) +---@field jokers JokerCard[] Array of joker cards + +-- ============================================================================= +-- Utility Module (implemented in utils.lua) +-- ============================================================================= + +---Utility functions for game state extraction and data processing +---@class utils +---@field get_game_state fun(): GameStateResponse Extracts the current game state +---@field table_to_json fun(obj: any, depth?: number): string Converts a Lua table to JSON string + +-- ============================================================================= +-- Configuration Types (used in balatrobot.lua) +-- ============================================================================= + +---@class BalatrobotConfig +---@field port string Port for the bot to listen on +---@field dt number Tells the game that every update is dt seconds long +---@field max_fps integer? Maximum frames per second +---@field vsync_enabled boolean Whether vertical sync is enabled diff --git a/src/lua/utils.lua b/src/lua/utils.lua index 7173462..5dd8251 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -1,6 +1,9 @@ +---Utility functions for game state extraction and data processing utils = {} local json = require("json") +---Extracts the current game state including game info, hand, and jokers +---@return GameStateResponse The complete game state function utils.get_game_state() local game = nil if G.GAME then @@ -62,6 +65,10 @@ function utils.get_game_state() } end +---Converts a Lua table to JSON string with depth limiting to prevent infinite recursion +---@param obj any The object to convert to JSON +---@param depth? number Maximum depth to traverse (default: 3) +---@return string JSON string representation of the object function utils.table_to_json(obj, depth) depth = depth or 3 From 14c4f683a7e03b780d8397acc3e4c31315bc3e62 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 11:19:43 +0200 Subject: [PATCH 49/60] chore: add dump folder to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e666369..f6a998c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ src/lua_old balatrobot_old.lua scripts balatro.sh +dump From b20da2c1ddf40f9b7d0e454f859cda6e35b858f3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 11:22:23 +0200 Subject: [PATCH 50/60] feat(api): add shop action support with next_round functionality - Add shop function to handle shop state actions - Implement next_round action to transition from shop to blind select - Add ShopActionArgs type definition with action field - Include validation for shop state before action execution - Add pending request handling for state transitions Co-Authored-By: Claude --- src/lua/api.lua | 29 +++++++++++++++++++++++++++++ src/lua/types.lua | 6 ++++++ 2 files changed, 35 insertions(+) diff --git a/src/lua/api.lua b/src/lua/api.lua index 2f680b4..3af9474 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -334,6 +334,35 @@ API.functions["cash_out"] = function(_) } end +---Selects an action for shop +---@param args ShopActionArgs The shop action to perform +API.functions["shop"] = function(args) + -- Validate current game state is appropriate for shop + if G.STATE ~= G.STATES.SHOP then + API.send_error_response("Cannot select shop action when not in shop", { current_state = G.STATE }) + return + end + + local action = args.action + if action == "next_round" then + G.FUNCS.toggle_shop({}) + API.pending_requests["shop"] = { + condition = function() + return G.STATE == G.STATES.BLIND_SELECT + and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD + and G.STATE_COMPLETE + end, + action = function() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } + else + API.send_error_response("Invalid action arg for shop", { action = action }) + return + end +end + ---Selects an action for booster packs (TODO implement) ---@param _ table Arguments API.functions["select_booster_action"] = function(_) diff --git a/src/lua/types.lua b/src/lua/types.lua index 77180b2..d4c92c7 100644 --- a/src/lua/types.lua +++ b/src/lua/types.lua @@ -46,6 +46,12 @@ ---@field action "play_hand" | "discard" The action to perform ---@field cards number[] Array of card indices (0-based) +---@class ShopActionArgs +---@field action "next_round" The action to perform + +-- TODO: add the other actions "reroll" | "buy" | "buy_and_use" | "redeem" | "open" +--@field item number? The item to buy/buy_and_use/redeem/open (0-based) + -- ============================================================================= -- Main API Module (defined in api.lua) -- ============================================================================= From 34ec39d8ac602c7e2044484dc37d9d4026ff4be5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 11:23:18 +0200 Subject: [PATCH 51/60] test(api): add comprehensive test suite for shop API endpoint - Add TestShop class with setup/teardown for shop state testing - Test successful next_round action transitions from shop to blind select - Test invalid action error handling with proper error response validation - Test invalid state error when shop called outside shop state - Include 3 test methods covering success and error cases Co-Authored-By: Claude --- tests/test_api_functions.py | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index 6976f9e..a050aa1 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -448,6 +448,80 @@ def test_play_hand_or_discard_invalid_state( ) +class TestShop: + """Tests for the shop API endpoint.""" + + @pytest.fixture(autouse=True) + def setup_and_teardown( + self, udp_client: socket.socket + ) -> Generator[None, None, None]: + """Set up and tear down each test method.""" + # Start a run + start_run_args = { + "deck": "Red Deck", + "stake": 1, + "challenge": None, + "seed": "OOOO155", # four of a kind in first hand + } + send_and_receive_api_message(udp_client, "start_run", start_run_args) + + # Select blind + send_and_receive_api_message( + udp_client, "skip_or_select_blind", {"action": "select"} + ) + + # Play a winning hand (four of a kind) to reach shop + game_state = send_and_receive_api_message( + udp_client, + "play_hand_or_discard", + {"action": "play_hand", "cards": [0, 1, 2, 3]}, + ) + assert game_state["state"] == State.ROUND_EVAL.value + + # Cash out to reach shop + game_state = send_and_receive_api_message(udp_client, "cash_out", {}) + assert game_state["state"] == State.SHOP.value + yield + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + def test_shop_next_round_success(self, udp_client: socket.socket) -> None: + """Test successful shop next_round action transitions to blind select.""" + # Execute next_round action + game_state = send_and_receive_api_message( + udp_client, "shop", {"action": "next_round"} + ) + + # Verify we're in blind select state after next_round + assert game_state["state"] == State.BLIND_SELECT.value + + def test_shop_invalid_action_error(self, udp_client: socket.socket) -> None: + """Test shop returns error for invalid action.""" + # Try invalid action + response = send_and_receive_api_message( + udp_client, "shop", {"action": "invalid_action"} + ) + + # Verify error response + assert_error_response( + response, "Invalid action arg for shop", ["action"] + ) + + def test_shop_invalid_state_error(self, udp_client: socket.socket) -> None: + """Test shop returns error when not in shop state.""" + # Go to menu first to ensure we're not in shop state + send_and_receive_api_message(udp_client, "go_to_menu", {}) + + # Try to use shop when not in shop state - should return error + response = send_and_receive_api_message( + udp_client, "shop", {"action": "next_round"} + ) + + # Verify error response + assert_error_response( + response, "Cannot select shop action when not in shop", ["current_state"] + ) + + class TestCashOut: """Tests for the cash_out API endpoint.""" From e3128818c1b21aabb46aaea991ed011b8ec1f7c7 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 11:23:54 +0200 Subject: [PATCH 52/60] docs(dev): update test suite metrics after shop API addition - Update test count from 35 to 38 tests - Update execution time from ~80 to ~110 seconds - Reflects addition of shop API test suite --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c6a4c2..bcba8b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,8 @@ pytest -v - **JSON metadata errors**: Normal for development files (.vscode, .luarc.json) - can be ignored 4. **Test execution**: - - **Test suite**: 35 tests covering API functions and UDP communication - - **Execution time**: ~80 seconds (includes game state transitions) + - **Test suite**: 38 tests covering API functions and UDP communication + - **Execution time**: ~110 seconds (includes game state transitions) - **Coverage**: API function calls, socket communication, error handling, edge cases 5. **Troubleshooting test failures**: From 7588ae05efa824c8c2111c48c6468ff9140f0a4b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 11:33:54 +0200 Subject: [PATCH 53/60] style(test): reformat multi-line function call to single line Simplified assert_error_response call formatting in shop test for consistency. --- tests/test_api_functions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_api_functions.py b/tests/test_api_functions.py index a050aa1..8b47c78 100644 --- a/tests/test_api_functions.py +++ b/tests/test_api_functions.py @@ -502,9 +502,7 @@ def test_shop_invalid_action_error(self, udp_client: socket.socket) -> None: ) # Verify error response - assert_error_response( - response, "Invalid action arg for shop", ["action"] - ) + assert_error_response(response, "Invalid action arg for shop", ["action"]) def test_shop_invalid_state_error(self, udp_client: socket.socket) -> None: """Test shop returns error when not in shop state.""" From f2c5b12656dcc56e78b8ade29d2e0c0b665d9b61 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 11:34:31 +0200 Subject: [PATCH 54/60] chore(dev): add tests directory to ruff format command Include tests/ in the ruff format command to ensure consistent formatting across test files. --- .claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index c92cfc4..8bd4d65 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,7 @@ }, { "type": "command", - "command": "ruff format -s src/balatrobot" + "command": "ruff format -s src/balatrobot tests/" } ] } From 7d9d1d5b158ef0e8610e0b39ebd5d25885ef5e58 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 14:26:30 +0200 Subject: [PATCH 55/60] refactor(api): improve code quality and documentation - Add detailed documentation for main API functions - Remove placeholder TODO functions to reduce code bloat - Replace string-based G.FUNCS calls with direct function calls - Enhance type annotations and inline comments - Clean up blind selection logic with better variable naming Co-Authored-By: Claude --- src/lua/api.lua | 77 ++++++++++++++----------------------------------- 1 file changed, 22 insertions(+), 55 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index 3af9474..49d71d7 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -120,7 +120,8 @@ API.functions["get_game_state"] = function(_) API.send_response(game_state) end ----Navigates to the main menu +---Navigates to the main menu. +---Call G.FUNCS.go_to_menu() to navigate to the main menu. ---@param _ table Arguments (not used) API.functions["go_to_menu"] = function(_) if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then @@ -143,12 +144,12 @@ API.functions["go_to_menu"] = function(_) end ---Starts a new game run with specified parameters +---Call G.FUNCS.start_run() to start a new game run with specified parameters. ---@param args StartRunArgs The run configuration API.functions["start_run"] = function(args) -- Reset the game - local play_button = G.MAIN_MENU_UI:get_UIE_by_ID("main_menu_play") - G.FUNCS[play_button.config.button]({ config = {} }) - G.FUNCS.exit_overlay_menu({}) + G.FUNCS.setup_run({ config = {} }) + G.FUNCS.exit_overlay_menu() -- Set the deck local deck_found = false @@ -194,6 +195,7 @@ API.functions["start_run"] = function(args) end ---Skips or selects the current blind +---Call G.FUNCS.select_blind(button) or G.FUNCS.skip_blind(button) ---@param args BlindActionArgs The blind action to perform API.functions["skip_or_select_blind"] = function(args) -- Validate current game state is appropriate for blind selection @@ -202,11 +204,14 @@ API.functions["skip_or_select_blind"] = function(args) return end + -- Get the current blind pane local current_blind = G.GAME.blind_on_deck - local blind_obj = G.blind_select_opts[string.lower(current_blind)] + assert(current_blind, "current_blind is nil") + local blind_pane = G.blind_select_opts[string.lower(current_blind)] + if args.action == "select" then - button = blind_obj:get_UIE_by_ID("select_blind_button") - G.FUNCS[button.config.button](button) + local button = blind_pane:get_UIE_by_ID("select_blind_button") + G.FUNCS.select_blind(button) API.pending_requests["skip_or_select_blind"] = { condition = function() return G.GAME and G.GAME.facing_blind and G.STATE == G.STATES.SELECTING_HAND @@ -218,13 +223,14 @@ API.functions["skip_or_select_blind"] = function(args) args = args, } elseif args.action == "skip" then - button = blind_obj:get_UIE_by_ID("tag_" .. current_blind).children[2] - G.FUNCS[button.config.button](button) + local tag_element = blind_pane:get_UIE_by_ID("tag_" .. current_blind) + local button = tag_element.children[2] + G.FUNCS.skip_blind(button) API.pending_requests["skip_or_select_blind"] = { condition = function() local prev_state = { ["Small"] = G.prev_small_state, - ["Large"] = G.prev_large_state, + ["Large"] = G.prev_large_state, -- this is Large not Big ["Boss"] = G.prev_boss_state, } return prev_state[current_blind] == "Skipped" @@ -241,6 +247,8 @@ API.functions["skip_or_select_blind"] = function(args) end ---Plays selected cards or discards them +---Call G.FUNCS.play_cards_from_highlighted(play_button) +---or G.FUNCS.discard_cards_from_highlighted(discard_button) ---@param args HandActionArgs The hand action to perform API.functions["play_hand_or_discard"] = function(args) -- Validate current game state is appropriate for playing hand or discarding @@ -278,11 +286,11 @@ API.functions["play_hand_or_discard"] = function(args) if args.action == "play_hand" then ---@diagnostic disable-next-line: undefined-field local play_button = UIBox:get_UIE_by_ID("play_button", G.buttons.UIRoot) - G.FUNCS["play_cards_from_highlighted"](play_button) + G.FUNCS.play_cards_from_highlighted(play_button) elseif args.action == "discard" then ---@diagnostic disable-next-line: undefined-field local discard_button = UIBox:get_UIE_by_ID("discard_button", G.buttons.UIRoot) - G.FUNCS["discard_cards_from_highlighted"](discard_button) + G.FUNCS.discard_cards_from_highlighted(discard_button) else API.send_error_response("Invalid action arg for play_hand_or_discard", { action = args.action }) return @@ -291,7 +299,6 @@ API.functions["play_hand_or_discard"] = function(args) -- Defer sending response until the run has started API.pending_requests["play_hand_or_discard"] = { condition = function() - -- TODO: maybe remove brittle G.E_MANAGER check if #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE then -- round still going if G.buttons and G.STATE == G.STATES.SELECTING_HAND then @@ -314,6 +321,7 @@ API.functions["play_hand_or_discard"] = function(args) end ---Cashes out from the current round to enter the shop +---Call G.FUNCS.cash_out() to cash out from the current round to enter the shop. ---@param _ table Arguments (not used) API.functions["cash_out"] = function(_) -- Validate current game state is appropriate for cash out @@ -335,6 +343,7 @@ API.functions["cash_out"] = function(_) end ---Selects an action for shop +---Call G.FUNCS.toggle_shop() to select an action for shop. ---@param args ShopActionArgs The shop action to perform API.functions["shop"] = function(args) -- Validate current game state is appropriate for shop @@ -363,46 +372,4 @@ API.functions["shop"] = function(args) end end ----Selects an action for booster packs (TODO implement) ----@param _ table Arguments -API.functions["select_booster_action"] = function(_) - -- TODO: implement -end - ----Selects an action in the shop (TODO implement) ----@param _ table Arguments -API.functions["select_shop_action"] = function(_) - -- TODO: implement -end - ----Rearranges cards in hand (TODO implement) ----@param _ table Arguments -API.functions["rearrange_hand"] = function(_) - -- TODO: implement -end - ----Rearranges consumable cards (TODO implement) ----@param _ table Arguments -API.functions["rearrange_consumables"] = function(_) - -- TODO: implement -end - ----Rearranges joker cards (TODO implement) ----@param _ table Arguments -API.functions["rearrange_jokers"] = function(_) - -- TODO: implement -end - ----Uses or sells consumable cards (TODO implement) ----@param _ table Arguments -API.functions["use_or_sell_consumables"] = function(_) - -- TODO: implement -end - ----Sells joker cards (TODO implement) ----@param _ table Arguments -API.functions["sell_jokers"] = function(_) - -- TODO: implement -end - return API From 10bc0ee4c2438ca2cb611dd5ed0fa02592c71bf3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 16:55:53 +0200 Subject: [PATCH 56/60] docs(dev): update commit command co-author handling Simplify workflow by removing breaking change step and update co-author section to use parameterized arguments system. --- .claude/commands/commit.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 1679da5..01a96e7 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -29,9 +29,13 @@ Analyze the git diff of staged files and create a commit message following conve 5. Choose appropriate type and scope based on changes 6. Write concise description (50 chars max for first line) 7. Include body if changes are complex -8. Add breaking change footer if applicable -9. If this is the final commit after extensive Claude assistance with code creation/modification/deletion, add `Co-Authored-By: Claude ` at the end of the commit body -10. Commit the staged changes with the generated message +8. Commit the staged changes with the generated message + +**Co-authors** +Add the following co-authors: $ARGUMENTS +If the list is empty, do not add any co-authors. +Here is a list of co-authors (name, co-authored-by): +- claude: `Co-Authored-By: Claude ` **Notes** - Do not include emojis in the commit message. From e4891a74ab213afb46e6191e21cdbaad4fed9156 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 16:57:48 +0200 Subject: [PATCH 57/60] refactor(api): move debug functionality to utils module Relocate debugplus integration from main mod file to utils.lua for better organization and add section headers for clarity. --- balatrobot.lua | 14 -------------- src/lua/utils.lua | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 4187e69..8582d4f 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -13,24 +13,10 @@ BALATRO_BOT_CONFIG = { -- max_fps = nil, } --- Load debug -local success, dpAPI = pcall(require, "debugplus-api") - -- Load minimal required files assert(SMODS.load_file("src/lua/utils.lua"))() assert(SMODS.load_file("src/lua/api.lua"))() -if success and dpAPI.isVersionCompatible(1) then - local debugplus = dpAPI.registerID("balatrobot") - debugplus.addCommand({ - name = "env", - shortDesc = "Get game state", - desc = "Get the current game state, useful for debugging", - exec = function(args, _, _) - debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G.GAME, 2) .. "}") - end, - }) -end -- Initialize API API.init() diff --git a/src/lua/utils.lua b/src/lua/utils.lua index 5dd8251..3a77bc3 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -2,6 +2,10 @@ utils = {} local json = require("json") +-- ========================================================================== +-- Game State Extraction +-- ========================================================================== + ---Extracts the current game state including game info, hand, and jokers ---@return GameStateResponse The complete game state function utils.get_game_state() @@ -65,6 +69,10 @@ function utils.get_game_state() } end +-- ========================================================================== +-- Debugging Utilities +-- ========================================================================== + ---Converts a Lua table to JSON string with depth limiting to prevent infinite recursion ---@param obj any The object to convert to JSON ---@param depth? number Maximum depth to traverse (default: 3) @@ -103,4 +111,19 @@ function utils.table_to_json(obj, depth) return json.encode(sanitized) end +-- Load debug +local success, dpAPI = pcall(require, "debugplus-api") + +if success and dpAPI.isVersionCompatible(1) then + local debugplus = dpAPI.registerID("balatrobot") + debugplus.addCommand({ + name = "env", + shortDesc = "Get game state", + desc = "Get the current game state, useful for debugging", + exec = function(args, _, _) + debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G.GAME, 2) .. "}") + end, + }) +end + return utils From bf6509ebc829764d3095e47bb595dc313a2d601d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 16:59:29 +0200 Subject: [PATCH 58/60] style(api): update log tags from BALATROBOT to API Standardize logging tags to use "API" prefix for better organization and consistency across the API module. --- src/lua/api.lua | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index 49d71d7..fde25ab 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -25,7 +25,7 @@ function API.update(_) API.socket:settimeout(SOCKET_TIMEOUT) local port = BALATRO_BOT_CONFIG.port API.socket:setsockname("127.0.0.1", tonumber(port) or 12346) - sendDebugMessage("UDP socket created on port " .. port, "BALATROBOT") + sendDebugMessage("UDP socket created on port " .. port, "API") end -- Process pending requests @@ -56,16 +56,16 @@ function API.update(_) local func = API.functions[data.name] local args = data.arguments if func == nil then - API.send_error_response("Unknown function name", { function_name = data.name }) + API.send_error_response("Unknown function name", { name = data.name }) elseif type(args) ~= "table" then API.send_error_response("Arguments must be a table", { received_type = type(args) }) else - sendDebugMessage(data.name .. "(" .. json.encode(args) .. ")", "BALATROBOT") + sendDebugMessage(data.name .. "(" .. json.encode(args) .. ")", "API") func(args) end end elseif client_ip ~= "timeout" then - sendErrorMessage("UDP error: " .. tostring(client_ip), "BALATROBOT") + sendErrorMessage("UDP error: " .. tostring(client_ip), "API") end end @@ -81,7 +81,7 @@ end ---@param message string The error message ---@param context? table Optional additional context about the error function API.send_error_response(message, context) - sendErrorMessage(message, "BALATROBOT") + sendErrorMessage(message, "API") local response = { error = message, state = G.STATE } if context then response.context = context @@ -106,7 +106,7 @@ function API.init() G.FPS_CAP = 60 end - sendInfoMessage("BalatrobotAPI initialized", "BALATROBOT") + sendInfoMessage("BalatrobotAPI initialized", "API") end -------------------------------------------------------------------------------- @@ -125,7 +125,7 @@ end ---@param _ table Arguments (not used) API.functions["go_to_menu"] = function(_) if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then - sendDebugMessage("go_to_menu called but already in menu", "BALATROBOT") + sendDebugMessage("go_to_menu called but already in menu", "API") local game_state = utils.get_game_state() API.send_response(game_state) return @@ -155,7 +155,7 @@ API.functions["start_run"] = function(args) local deck_found = false for _, v in pairs(G.P_CENTER_POOLS.Back) do if v.name == args.deck then - sendDebugMessage("Changing to deck: " .. v.name, "BALATROBOT") + sendDebugMessage("Changing to deck: " .. v.name, "API") G.GAME.selected_back:change_to(v) G.GAME.viewed_back:change_to(v) deck_found = true From 7bd99e39042b459a04e58a5e724de1e80e40fafe Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 17:01:32 +0200 Subject: [PATCH 59/60] test(api): fix error context field name in test Update test to match API change from "function_name" to "name" in error response context. --- tests/test_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_connection.py b/tests/test_connection.py index 8a3b467..e181e2d 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -117,7 +117,7 @@ def test_unknown_message(udp_client: socket.socket) -> None: data, _ = udp_client.recvfrom(BUFFER_SIZE) response = data.decode().strip() error_response = json.loads(response) - assert_error_response(error_response, "Unknown function name", ["function_name"]) + assert_error_response(error_response, "Unknown function name", ["name"]) # Verify server is still responsive send_api_message(udp_client, "get_game_state", {}) From 3b1ab6d75a0370044eebba9eef18a4d1a2194f10 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 11 Jul 2025 17:42:59 +0200 Subject: [PATCH 60/60] docs(dev): improve code documentation and comments - Add detailed documentation for EVENT_QUEUE_THRESHOLD constant explaining the heuristic for game state stability - Document DebugPlus integration with reference to GitHub repo and optional dependency behavior - Enhance assert_error_response docstring with parameter descriptions and usage details Co-Authored-By: Claude --- src/lua/api.lua | 5 +++++ src/lua/utils.lua | 7 ++++++- tests/conftest.py | 15 ++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/lua/api.lua b/src/lua/api.lua index fde25ab..bedc87d 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -4,6 +4,11 @@ local json = require("json") -- Constants local UDP_BUFFER_SIZE = 65536 local SOCKET_TIMEOUT = 0 +-- The threshold for determining when game state transitions are complete. +-- This value represents the maximum number of events allowed in the game's event queue +-- to consider the game idle and waiting for user action. When the queue has fewer than +-- 3 events, the game is considered stable enough to process API responses. This is a +-- heuristic based on empirical testing to ensure smooth gameplay without delays. local EVENT_QUEUE_THRESHOLD = 3 API = {} API.socket = nil diff --git a/src/lua/utils.lua b/src/lua/utils.lua index 3a77bc3..be5f91d 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -111,7 +111,12 @@ function utils.table_to_json(obj, depth) return json.encode(sanitized) end --- Load debug +-- Load DebugPlus integration +-- Attempt to load the optional DebugPlus mod (https://github.com/WilsontheWolf/DebugPlus/tree/master). +-- DebugPlus is a Balatro mod that provides additional debugging utilities for mod development, +-- such as custom debug commands and structured logging. It is not required for core functionality +-- and is primarily intended for development and debugging purposes. If the module is unavailable +-- or incompatible, the program will continue to function without it. local success, dpAPI = pcall(require, "debugplus-api") if success and dpAPI.isVersionCompatible(1) then diff --git a/tests/conftest.py b/tests/conftest.py index cc1224a..7617fac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,7 +71,20 @@ def send_and_receive_api_message( def assert_error_response(response, expected_error_text, expected_context_keys=None): - """Helper function to assert error response format and content.""" + """ + Helper function to assert the format and content of an error response. + + Args: + response (dict): The response dictionary to validate. Must contain at least + the keys "error" and "state". + expected_error_text (str): The expected error message text to check within + the "error" field of the response. + expected_context_keys (list, optional): A list of keys expected to be present + in the "context" field of the response, if the "context" field exists. + + Raises: + AssertionError: If the response does not match the expected format or content. + """ assert isinstance(response, dict) assert "error" in response assert "state" in response