Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/release_please.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
workflow_run:
workflows: ["Code Quality"]
Expand Down
16 changes: 8 additions & 8 deletions docs/developing-bots.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ BalatroBot allows you to create automated players (bots) that can play Balatro b
A bot is a finite state machine that implements a sequence of actions to play the game.
The bot can be in one state at a time and have access to a set of functions that can move the bot to other states.

| **State** | **Description** | **Functions** |
| ---------------- | -------------------------------------------- | ---------------------- |
| `MENU` | The main menu | `start_run` |
| `BLIND_SELECT` | Selecting or skipping the blind | `skip_or_select_blind` |
| `SELECTING_HAND` | Selecting cards to play or discard | `play_hand_or_discard` |
| `ROUND_EVAL` | Evaluating the round outcome and cashing out | `cash_out` |
| `SHOP` | Buy items and move to the next round | `shop` |
| `GAME_OVER` | Game has ended | – |
| **State** | **Description** | **Functions** |
| ---------------- | -------------------------------------------- | ---------------------------------------- |
| `MENU` | The main menu | `start_run` |
| `BLIND_SELECT` | Selecting or skipping the blind | `skip_or_select_blind` |
| `SELECTING_HAND` | Selecting cards to play or discard | `play_hand_or_discard`, `rearrange_hand` |
| `ROUND_EVAL` | Evaluating the round outcome and cashing out | `cash_out` |
| `SHOP` | Buy items and move to the next round | `shop` |
| `GAME_OVER` | Game has ended | – |

Developing a bot boils down to provide the action name and its parameters for each of the

Expand Down
23 changes: 11 additions & 12 deletions docs/protocol-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,7 @@ All communication uses JSON messages with a standardized structure. The protocol
"name": "function_name",
"arguments": {
"param1": "value1",
"param2": [
"array",
"values"
]
"param2": ["array", "values"]
}
}
```
Expand Down Expand Up @@ -86,14 +83,14 @@ The BalatroBot API operates as a finite state machine that mirrors the natural f

The game progresses through these states in a typical flow: `MENU` → `BLIND_SELECT` → `SELECTING_HAND` → `ROUND_EVAL` → `SHOP` → `BLIND_SELECT` (or `GAME_OVER`).

| State | Value | Description | Available Functions |
| ---------------- | ----- | ---------------------------- | ---------------------- |
| `MENU` | 11 | Main menu screen | `start_run` |
| `BLIND_SELECT` | 7 | Selecting or skipping blinds | `skip_or_select_blind` |
| `SELECTING_HAND` | 1 | Playing or discarding cards | `play_hand_or_discard` |
| `ROUND_EVAL` | 8 | Round completion evaluation | `cash_out` |
| `SHOP` | 5 | Shop interface | `shop` |
| `GAME_OVER` | 4 | Game ended | `go_to_menu` |
| State | Value | Description | Available Functions |
| ---------------- | ----- | ---------------------------- | ---------------------------------------- |
| `MENU` | 11 | Main menu screen | `start_run` |
| `BLIND_SELECT` | 7 | Selecting or skipping blinds | `skip_or_select_blind` |
| `SELECTING_HAND` | 1 | Playing or discarding cards | `play_hand_or_discard`, `rearrange_hand` |
| `ROUND_EVAL` | 8 | Round completion evaluation | `cash_out` |
| `SHOP` | 5 | Shop interface | `shop` |
| `GAME_OVER` | 4 | Game ended | `go_to_menu` |

### Validation

Expand All @@ -119,6 +116,7 @@ The BalatroBot API provides core functions that correspond to the main game acti
| `start_run` | Starts a new game run with specified configuration |
| `skip_or_select_blind` | Handles blind selection - either select the current blind to play or skip it |
| `play_hand_or_discard` | Plays selected cards or discards them |
| `rearrange_hand` | Reorders the current hand according to the supplied index list |
| `cash_out` | Proceeds from round completion to the shop phase |
| `shop` | Performs shop actions. Currently supports proceeding to the next round |

Expand All @@ -131,6 +129,7 @@ The following table details the parameters required for each function. Note that
| `start_run` | `deck` (string): Deck name<br>`stake` (number): Difficulty level 1-8<br>`seed` (string, optional): Seed for run generation<br>`challenge` (string, optional): Challenge name |
| `skip_or_select_blind` | `action` (string): Either "select" or "skip" |
| `play_hand_or_discard` | `action` (string): Either "play_hand" or "discard"<br>`cards` (array): Card indices (0-indexed, 1-5 cards) |
| `rearrange_hand` | `action` (string): Must be "rearrange_hand"<br>`cards` (array): Card indices (0-indexed, exactly `hand_size` elements) |
Copy link

Copilot AI Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation specifies that the action parameter must be "rearrange_hand", but the type definition in types.lua defines it as "rearrange". This inconsistency should be resolved.

Suggested change
| `rearrange_hand` | `action` (string): Must be "rearrange_hand"<br>`cards` (array): Card indices (0-indexed, exactly `hand_size` elements) |
| `rearrange_hand` | `action` (string): Must be "rearrange"<br>`cards` (array): Card indices (0-indexed, exactly `hand_size` elements) |

Copilot uses AI. Check for mistakes.
| `shop` | `action` (string): Shop action to perform ("next_round") |

### Errors
Expand Down
78 changes: 78 additions & 0 deletions src/lua/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,84 @@ API.functions["play_hand_or_discard"] = function(args)
}
end

---Rearranges the hand based on the given card indices
---Call G.FUNCS.rearrange_hand(new_hand)
---@param args RearrangeHandArgs The card indices to rearrange the hand with
API.functions["rearrange_hand"] = function(args)
-- Validate required parameters
local success, error_message, error_code, context = validate_request(args, { "cards" })

if not success then
---@cast error_message string
---@cast error_code string
API.send_error_response(error_message, error_code, context)
return
end

-- Validate current game state is appropriate for rearranging cards
if G.STATE ~= G.STATES.SELECTING_HAND then
API.send_error_response(
"Cannot rearrange hand when not selecting hand",
ERROR_CODES.INVALID_GAME_STATE,
{ current_state = G.STATE }
)
return
end

-- Validate number of cards is equal to the number of cards in hand
if #args.cards ~= #G.hand.cards then
API.send_error_response(
"Invalid number of cards to rearrange",
ERROR_CODES.PARAMETER_OUT_OF_RANGE,
{ cards_count = #args.cards, valid_range = tostring(#G.hand.cards) }
)
return
end

-- Convert incoming indices from 0-based to 1-based
for i, card_index in ipairs(args.cards) do
args.cards[i] = card_index + 1
end

-- Create a new hand to swap card indices
local new_hand = {}
for _, old_index in ipairs(args.cards) do
local card = G.hand.cards[old_index]
if not card then
API.send_error_response(
"Card index out of range",
ERROR_CODES.PARAMETER_OUT_OF_RANGE,
{ index = old_index, max_index = #G.hand.cards }
)
return
end
table.insert(new_hand, card)
end

G.hand.cards = new_hand

-- Update each card's order field so future sort('order') calls work correctly
for i, card in ipairs(G.hand.cards) do
card.config.card.order = i
if card.config.center then
card.config.center.order = i
end
end

---@type PendingRequest
API.pending_requests["rearrange_hand"] = {
condition = function()
return G.STATE == G.STATES.SELECTING_HAND
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

---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)
Expand Down
65 changes: 65 additions & 0 deletions src/lua/log.lua
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,70 @@ function LOG.hook_toggle_shop()
sendDebugMessage("Hooked into G.FUNCS.toggle_shop for logging", "LOG")
end

-- -----------------------------------------------------------------------------
-- hand_rearrange Hook
-- -----------------------------------------------------------------------------

---Hooks into CardArea:align_cards for hand reordering detection
function LOG.hook_hand_rearrange()
local original_function = CardArea.align_cards
local previous_order = {}
CardArea.align_cards = function(self, ...)
-- Only monitor hand cards
---@diagnostic disable-next-line: undefined-field
if self.config and self.config.type == "hand" and self.cards then
-- Call the original function with all arguments
local result = original_function(self, ...)

---@diagnostic disable-next-line: undefined-field
if self.config.card_count ~= #self.cards then
-- We're drawing cards from the deck
return result
end

-- Capture current card order after alignment
local current_order = {}
---@diagnostic disable-next-line: undefined-field
for i, card in ipairs(self.cards) do
current_order[i] = card.sort_id
end

if utils.sets_equal(previous_order, current_order) then
local order_changed = false
for i = 1, #current_order do
if previous_order[i] ~= current_order[i] then
order_changed = true
break
end
end

if order_changed then
-- TODO: compute the rearrangement from previous_order and current_order
-- and use as the arguments to the rearrange_hand API call
-- So remove previous_order and current_order and use cards
-- Then remove sendInfoMessage calls
local arguments = {
previous_order = previous_order,
current_order = current_order,
}
local name = "rearrange_hand"
sendInfoMessage("Hand rearranged - cards reordered", "LOG")
sendInfoMessage("Before: " .. json.encode(previous_order), "LOG")
sendInfoMessage("After: " .. json.encode(current_order), "LOG")
LOG.write(name, arguments)
end
end

previous_order = current_order
return result
else
-- For non-hand card areas, just call the original function
return original_function(self, ...)
end
end
sendInfoMessage("Hooked into CardArea:align_cards for hand rearrange logging", "LOG")
end

-- TODO: add hooks for other shop functions

-- =============================================================================
Expand All @@ -216,6 +280,7 @@ function LOG.init()
LOG.hook_discard_cards_from_highlighted()
LOG.hook_cash_out()
LOG.hook_toggle_shop()
LOG.hook_hand_rearrange()

sendInfoMessage("Logger initialized", "LOG")
end
Expand Down
4 changes: 4 additions & 0 deletions src/lua/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
---@field action "play_hand" | "discard" The action to perform
---@field cards number[] Array of card indices (0-based)

---@class RearrangeHandArgs
---@field action "rearrange" The action to perform
Copy link

Copilot AI Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action field value "rearrange" is inconsistent with the function name "rearrange_hand". Consider using "rearrange_hand" for consistency with other API endpoints.

Suggested change
---@field action "rearrange" The action to perform
---@field action "rearrange_hand" The action to perform

Copilot uses AI. Check for mistakes.
---@field cards number[] Array of card indices for every card in hand (0-based)

---@class ShopActionArgs
---@field action "next_round" The action to perform

Expand Down
24 changes: 24 additions & 0 deletions src/lua/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ function utils.get_game_state()
label = card.label, -- str (default "Base Card") | ... | ... | ?
-- playing_card = card.config.card.playing_card, -- int. The card index in the deck for the current round ?
-- sell_cost = card.sell_cost, -- int (default 1). The dollars you get if you sell this card ?
sort_id = card.sort_id, -- int. Unique identifier for this card instance
base = {
-- These should be the valude for the original base card
-- without any modifications
Expand Down Expand Up @@ -490,6 +491,29 @@ function utils.get_game_state()
}
end

-- ==========================================================================
-- Utility Functions
-- ==========================================================================

function utils.sets_equal(list1, list2)
if #list1 ~= #list2 then
return false
end

local set = {}
for _, v in ipairs(list1) do
set[v] = true
end

for _, v in ipairs(list2) do
if not set[v] then
return false
end
end

return true
end

-- ==========================================================================
-- Debugging Utilities
-- ==========================================================================
Expand Down
Loading