diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..01a96e7 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,42 @@ +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. 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 +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. 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. +- Do not include `🤖 Generated with [Claude Code](https://claude.ai/code)` in the commit message. 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/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8bd4d65 --- /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 tests/" + } + ] + } + ] + } +} 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 diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 4fd65c5..14cacca 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: latest + args: --check src/lua diff --git a/.gitignore b/.gitignore index 324c328..f6a998c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ gamestate_cache .envrc .luarc.json *.log +src/lua_old +balatrobot_old.lua +scripts +balatro.sh +dump 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" + ] +} 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 +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bcba8b1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 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 -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" + ``` + +2. **Monitor game startup**: + + ```bash + # Check logs for successful mod loading + tail -n 100 balatro.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**: + - **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**: 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**: + - **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 + +```bash +# Serve documentation locally +mkdocs serve + +# Build documentation +mkdocs build +``` + +## 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 `balatro.log` to confirm successful startup diff --git a/balatrobot.lua b/balatrobot.lua index 6c44de0..8582d4f 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -1,42 +1,24 @@ ----@meta balatrobot ----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 - ---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, + port = "12346", + dt = 4.0 / 60.0, -- value >= 4.0 make mod instable + 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, } -assert(SMODS.load_file("src/lua/list.lua"))() -assert(SMODS.load_file("src/lua/hook.lua"))() +-- 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") --- Init API (includes queue initialization) -BalatrobotAPI.init() -sendDebugMessage("API loaded", "BALATROBOT") +-- Initialize API +API.init() sendInfoMessage("BalatroBot loaded - version " .. SMODS.current_mod.version, "BALATROBOT") 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/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( diff --git a/src/lua/api.lua b/src/lua/api.lua index 1f4a446..bedc87d 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -1,198 +1,380 @@ 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 +-- 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 +API.functions = {} +API.pending_requests = {} +API.last_client_ip = nil +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) or 12346) + sendDebugMessage("UDP socket created on port " .. port, "API") + end + + -- Process pending requests + for key, request in pairs(API.pending_requests) do + if request.condition() then + request.action() + 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(UDP_BUFFER_SIZE) + 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 + + local ok, data = pcall(json.decode, raw_data) + if not ok then + API.send_error_response("Invalid JSON") + return + end + if data.name == nil then + API.send_error_response("Message must contain a name") + elseif data.arguments == nil then + API.send_error_response("Message must contain arguments") + else + local func = API.functions[data.name] + local args = data.arguments + if func == nil then + 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) .. ")", "API") + func(args) + end + end + elseif client_ip ~= "timeout" then + sendErrorMessage("UDP error: " .. tostring(client_ip), "API") + 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, "API") + local response = { error = message, state = G.STATE } + if context then + response.context = context + end + 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 + love.update = function(_) + 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", "API") +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. +---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 + sendDebugMessage("go_to_menu called but already in menu", "API") + 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() + local game_state = utils.get_game_state() + API.send_response(game_state) + 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) - 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 + G.FUNCS.setup_run({ 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, "API") + 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 + API.send_error_response("Invalid deck arg for start_run", { deck = 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() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } +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 + 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 + + -- Get the current blind pane + local current_blind = G.GAME.blind_on_deck + assert(current_blind, "current_blind is nil") + local blind_pane = G.blind_select_opts[string.lower(current_blind)] + + if args.action == "select" then + 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 + end, + action = function() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + args = args, + } + elseif args.action == "skip" then + 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, -- this is Large not Big + ["Boss"] = G.prev_boss_state, + } + return prev_state[current_blind] == "Skipped" + 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 skip_or_select_blind", { action = args.action }) + return + end end -function BalatrobotAPI.queueaction(action) - local _params = Bot.ACTIONPARAMS[action[1]] - List.pushleft(BalatrobotAPI["q_" .. _params.func], { 0, action }) +---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 + 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", + { discards_left = G.GAME.current_round.discards_left } + ) + 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 + end + + -- Check that all cards are selectable + for _, card_index in ipairs(args.cards) do + if not G.hand.cards[card_index] then + API.send_error_response("Invalid card index", { card_index = card_index, hand_size = #G.hand.cards }) + 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 + API.send_error_response("Invalid action arg for play_hand_or_discard", { action = args.action }) + return + end + + -- Defer sending response until the run has started + API.pending_requests["play_hand_or_discard"] = { + condition = function() + 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 + -- round won and entering cash out state (ROUND_EVAL state) + elseif G.STATE == G.STATES.ROUND_EVAL then + return true + -- game over state + elseif G.STATE == G.STATES.GAME_OVER then + return true + end + end + return false + end, + action = function() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } 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) - end - - data, msg_or_ip, port_or_nil = BalatrobotAPI.socket:receivefrom() - if data then - if data == "HELLO\n" or data == "HELLO" then - BalatrobotAPI.notifyapiclient() - 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]) - else - BalatrobotAPI.waitingForAction = false - BalatrobotAPI.queueaction(_action) - end - end - elseif msg_or_ip ~= "timeout" then - sendDebugMessage("Unknown network error: " .. tostring(msg)) - end - - -- No idea if this is necessary - -- Without this being commented out, FPS capped out at ~80 for me - -- socket.sleep(0.01) +---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 + if G.STATE ~= G.STATES.ROUND_EVAL then + 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 < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE + end, + action = function() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } 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) - end - - -- Disable FPS cap - if BALATRO_BOT_CONFIG.uncap_fps then - G.FPS_CAP = 999999.0 - end - - -- 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 - end - - -- Forcibly disable vsync - if BALATRO_BOT_CONFIG.disable_vsync then - love.window.setVSync(0) - end - - -- 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 - 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() - end - end - - local original_present = love.graphics.present - love.graphics.present = function() - if draw_count % BALATRO_BOT_CONFIG.frame_ratio == 0 then - original_present() - end - 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) +---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 + 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 -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/types.lua b/src/lua/types.lua new file mode 100644 index 0000000..d4c92c7 --- /dev/null +++ b/src/lua/types.lua @@ -0,0 +1,127 @@ +---@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) + +---@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) +-- ============================================================================= + +---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 4d64b71..be5f91d 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -1,247 +1,134 @@ - -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 +---Utility functions for game state extraction and data processing +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() + 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 - _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 + + 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 - return _shop -end - -function Utils.getHandScoreData() - local _handscores = { } - - return _handscores -end - -function Utils.getTagsData() - local _tags = { } + -- TODO: add consumables, ante, and shop - return _tags + return { + state = G.STATE, + game = game, + hand = hand, + jokers = jokers, + } 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 +-- ========================================================================== +-- Debugging Utilities +-- ========================================================================== -function Utils.getGameData() - local _game = { } +---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 - 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 + local function sanitize_for_json(value, current_depth) + if current_depth <= 0 then + return "..." 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 + 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 - 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] + return tostring(value) end + end - return true + local sanitized = sanitize_for_json(obj, depth) + return json.encode(sanitized) end -function Utils.isTableInRange(table, min, max) - if table == nil then return true end +-- 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") - for i = 1, #table do - if table[i] < min or table[i] > max then return false end - end - return true +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 \ No newline at end of file +return utils diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7617fac --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,95 @@ +"""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: int = 12346 # default port for BalatroBot UDP API +TIMEOUT: float = 10.0 # timeout for socket operations in seconds +BUFFER_SIZE: int = 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 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 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, 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 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 + 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 new file mode 100644 index 0000000..8b47c78 --- /dev/null +++ b/tests/test_api_functions.py @@ -0,0 +1,574 @@ +"""Tests for BalatroBot UDP API game functions.""" + +import socket +from typing import Generator + +import pytest +from conftest import assert_error_response, send_and_receive_api_message + +from balatrobot.enums import State + + +class TestGetGameState: + """Tests for the get_game_state 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_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(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", {}) + + 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_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": "EXAMPLE", + } + 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", {}) + + assert current_state["state"] == State.BLIND_SELECT.value + assert current_state["state"] == initial_state["state"] + + +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": "EXAMPLE", + } + game_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) + + 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": "EXAMPLE", + } + 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_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.""" + 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_error_response(response, "Invalid deck arg for start_run", ["deck"]) + + +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_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": 1, + "challenge": None, + "seed": "EXAMPLE", + } + initial_state = send_and_receive_api_message( + udp_client, "start_run", start_run_args + ) + assert initial_state["state"] == State.BLIND_SELECT.value + + # Now go to menu + menu_state = send_and_receive_api_message(udp_client, "go_to_menu", {}) + + assert menu_state["state"] == State.MENU.value + + +class TestSkipOrSelectBlind: + """Tests for the skip_or_select_blind 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_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 + 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 + 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.""" + # Should receive error response + error_response = send_and_receive_api_message( + udp_client, "skip_or_select_blind", {"action": "invalid_action"} + ) + + # Verify error response + assert_error_response( + 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.""" + + @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": "OOOO155", # four of a kind in first hand + }, + ) + 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", + [ + ([7, 6, 5, 4, 3], 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_winning(self, udp_client: socket.socket) -> None: + """Test playing a winning hand (four of a kind)""" + 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_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( + udp_client, "play_hand_or_discard", play_hand_args + ) + + # Should receive error response for invalid card index + 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.""" + 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_error_response( + response, "Invalid action arg for play_hand_or_discard", ["action"] + ) + + @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() + + 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_error_response( + 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 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.""" + + @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_error_response( + response, "Cannot cash out when not in shop", ["current_state"] + ) diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..e181e2d --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,156 @@ +"""Tests for BalatroBot UDP API connection and protocol handling.""" + +import json +import socket + +import pytest +from conftest import BUFFER_SIZE, HOST, PORT, assert_error_response, 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_error_response(error_response, "Invalid JSON") + + # 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_error_response(error_response, "Message must contain a name") + + # 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_error_response(error_response, "Message must contain arguments") + + # 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_error_response(error_response, "Unknown function name", ["name"]) + + # 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) 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"