Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
374ea21
fix(gamestate): use localize for voucher effect descriptions
S1M0N38 Feb 24, 2026
87f12bb
feat(api): add suggested actions to error messages
S1M0N38 Feb 24, 2026
660621c
test(lua.endpoints): fix test for vouchers effect
S1M0N38 Feb 25, 2026
de824d7
refactor(lua.endpoints): us the SMODS.add_voucher_to_shop for add vou…
S1M0N38 Feb 25, 2026
6437fae
feat: add support for Tags
S1M0N38 Feb 25, 2026
e0791ad
test(lua.endpoints): add test for tags support
S1M0N38 Feb 25, 2026
84f7c59
docs(lua.utils): fix the description of the enums tags
S1M0N38 Feb 25, 2026
e009e82
docs(api): add documentation for tags
S1M0N38 Feb 25, 2026
d40d645
fix: allow to sell jokers when a Buffoon pack is open
S1M0N38 Feb 25, 2026
73b6148
test(lua.endpoints): fix the assertion for the tags tests
S1M0N38 Feb 28, 2026
48c4cbc
Expose most_played_poker_hand and ancient_suit in round info
DrLatBC Apr 4, 2026
800078a
Add blind param to set endpoint for forcing boss blinds
DrLatBC Apr 4, 2026
af78bb3
Port local mod fixes: edition detection, pack robustness, sell/rearra…
DrLatBC Apr 4, 2026
3fc9d5a
Merge remote-tracking branch 'upstream/dev'
DrLatBC Apr 5, 2026
62b764d
Add win overlay auto-dismiss for endless mode support
DrLatBC Apr 5, 2026
457d403
Sync local mod changes: cash_out race fix, set debuff, sell/error cle…
DrLatBC Apr 11, 2026
2151d53
Revert cash_out scoring_complete guard — was masking a bot-side bug
DrLatBC Apr 12, 2026
34d0c16
feat(gamestate): expose idol_card in round info
DrLatBC Apr 18, 2026
c2116ba
fix(seed): real entropy for auto-generated seeds
DrLatBC Apr 18, 2026
4622886
Add earnings tracker for per-source money attribution
DrLatBC Apr 19, 2026
92051f9
feat(gamestate): expose held tags
DrLatBC Apr 19, 2026
b39f318
fix(earnings): drop bottom-row double-count, hook calculate_joker for…
DrLatBC Apr 20, 2026
2524d80
fix(pack): let skip bypass guard and pcall-wrap use_card to escape we…
DrLatBC Apr 21, 2026
6e43415
fix(pack): defer to Card:can_use_consumeable before use_card
DrLatBC Apr 21, 2026
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
4 changes: 2 additions & 2 deletions balatrobot.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"badge_colour": "4CAF50",
"badge_text_colour": "FFFFFF",
"display_name": "BB",
"version": "1.4.1",
"version": "1.4.0",
"dependencies": [
"Steamodded (>=1.~)"
"Steamodded (>=0.0.1)"
]
}
6 changes: 6 additions & 0 deletions balatrobot.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ BB_ENDPOINTS = {
"src/lua/endpoints/skip.lua",
"src/lua/endpoints/select.lua",
-- Play/discard endpoints
"src/lua/endpoints/highlight.lua",
"src/lua/endpoints/play.lua",
"src/lua/endpoints/discard.lua",
-- Cash out endpoint
Expand Down Expand Up @@ -56,6 +57,8 @@ assert(SMODS.load_file("src/lua/core/dispatcher.lua"))() -- define BB_DISPATCHER
-- Load gamestate and errors utilities
BB_GAMESTATE = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))()
assert(SMODS.load_file("src/lua/utils/errors.lua"))()
BB_EARNINGS = assert(SMODS.load_file("src/lua/utils/earnings.lua"))()
BB_EARNINGS.install()

-- Initialize Server
local server_success = BB_SERVER.init()
Expand All @@ -73,6 +76,9 @@ local love_update = love.update
love.update = function(dt) ---@diagnostic disable-line: duplicate-set-field
-- Check for GAME_OVER before game logic runs
BB_GAMESTATE.check_game_over()
-- Dismiss win overlay when paused — event handlers can't run while paused,
-- so we do it here in love.update which always runs
BB_GAMESTATE.check_win_overlay()
love_update(dt)
BB_SERVER.update(BB_DISPATCHER)
end
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="assets/balatrobot.svg" alt="BalatroBot" width="120">
</a>
<figcaption>
<a href="https://coder.github.io/balatrobot/"><span style="text-decoration: underline; text-underline-offset: 8px;">BalatroBot</span></a><br>
<a href="https://coder.github.io/balatrbot/"><span style="text-decoration: underline; text-underline-offset: 8px;">BalatroBot</span></a><br>
<small>API for developing Balatro bots</small>
</figcaption>
</figure>
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This guide covers installing the BalatroBot mod for Balatro.

### 1. Download BalatroBot

Download the latest release from the [releases page](https://github.com/coder/balatrobot/releases) or clone the repository.
Download the latest release from the [releases page](https://github.com/your-repo/balatrobot/releases) or clone the repository.

### 2. Copy to Mods Folder

Expand Down
32 changes: 32 additions & 0 deletions lovely/seed.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Replace Balatro's cursor-based auto-seed generator with real entropy.
#
# Balatro's generate_starting_seed() (functions/misc_functions.lua) derives
# randomness from the mouse cursor position and hover timestamp. In headless
# mode the cursor is stationary, so consecutive auto-seeded runs collide.
#
# Swap the cursor expression for os.time() + love.timer.getTime() (sub-µs
# monotonic clock) + love.math.random() (seeded from OS entropy at LÖVE
# startup). random_string() stays intact — only its numeric seed changes.

[manifest]
version = "1.0.0"
priority = 0
dump_lua = true

# Legendary-rerolling loop (stake >= #Stake pool).
[[patches]]
[patches.pattern]
target = "functions/misc_functions.lua"
pattern = "seed_found = random_string(8, extra_num + G.CONTROLLER.cursor_hover.T.x*0.33411983 + G.CONTROLLER.cursor_hover.T.y*0.874146 + 0.412311010*G.CONTROLLER.cursor_hover.time)"
position = "at"
match_indent = true
payload = "seed_found = random_string(8, extra_num + os.time() + (love.timer and love.timer.getTime() or 0) * 1000000 + love.math.random() * 2147483647)"

# Default path — ordinary new run.
[[patches]]
[patches.pattern]
target = "functions/misc_functions.lua"
pattern = "return random_string(8, G.CONTROLLER.cursor_hover.T.x*0.33411983 + G.CONTROLLER.cursor_hover.T.y*0.874146 + 0.412311010*G.CONTROLLER.cursor_hover.time)"
position = "at"
match_indent = true
payload = "return random_string(8, os.time() + (love.timer and love.timer.getTime() or 0) * 1000000 + love.math.random() * 2147483647)"
29 changes: 18 additions & 11 deletions src/lua/endpoints/buy.lua
Original file line number Diff line number Diff line change
Expand Up @@ -238,28 +238,35 @@ return {
end
elseif args.pack then
local money_deducted = (G.GAME.dollars == initial_money - card.cost.buy)
local pack_ready = (
G.pack_cards
and not G.pack_cards.REMOVED
and G.pack_cards.cards[1]
and G.STATE_COMPLETE
and G.STATE == G.STATES.SMODS_BOOSTER_OPENED
)
local has_pack = G.pack_cards and not G.pack_cards.REMOVED and G.pack_cards.cards and G.pack_cards.cards[1]
local state_ok = G.STATE_COMPLETE and G.STATE == G.STATES.SMODS_BOOSTER_OPENED
local pack_ready = has_pack and state_ok

if money_deducted and pack_ready then
-- Check if this pack type needs hand (Arcana/Spectral packs)
local pack_key = G.pack_cards.cards[1].ability and G.pack_cards.cards[1].ability.set
local needs_hand = pack_key == "Tarot" or pack_key == "Spectral"
-- Check if this pack type needs hand (Arcana/Spectral packs deal hand cards)
-- Don't infer pack type from the first card's set — Black Hole is
-- set=Spectral but appears in Celestial packs, causing a false match.
local needs_hand = G.hand and G.hand.cards and #G.hand.cards > 0

if needs_hand then
-- Wait for hand to be fully loaded and positioned
local hand_limit = G.hand and G.hand.config and G.hand.config.card_limit or 8
local deck_size = G.deck and G.deck.config and G.deck.config.card_count or 52
local expected = math.min(deck_size, hand_limit)
local hand_count = G.hand and G.hand.cards and #G.hand.cards or 0
local hand_ready = G.hand
and not G.hand.REMOVED
and G.hand.cards
and #G.hand.cards == hand_limit
and hand_count >= expected
and G.hand.T
and G.hand.T.x
local cards_positioned = hand_ready and G.hand.cards[1] and G.hand.cards[1].T and G.hand.cards[1].T.x
if not done and money_deducted then
sendDebugMessage(string.format(
"buy(pack) hand wait: count=%d expected=%d limit=%d deck=%d positioned=%s",
hand_count, expected, hand_limit, deck_size, tostring(cards_positioned ~= nil)
), "BB.ENDPOINTS")
end
done = hand_ready and cards_positioned
else
done = true
Expand Down
2 changes: 0 additions & 2 deletions src/lua/endpoints/discard.lua
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ return {
G.E_MANAGER:add_event(Event({
trigger = "immediate",
blocking = false,
blockable = false,
created_on_pause = true,
func = function()
-- State progression for discard:
-- Discard always continues current round: HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND
Expand Down
50 changes: 50 additions & 0 deletions src/lua/endpoints/highlight.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- src/lua/endpoints/highlight.lua

-- ==========================================================================
-- Highlight Endpoint Params
-- ==========================================================================

---@class Request.Endpoint.Highlight.Params
---@field card integer 0-based index of card to toggle highlight

-- ==========================================================================
-- Highlight Endpoint
-- ==========================================================================

---@type Endpoint
return {

name = "highlight",

description = "Toggle highlight on a single card in the hand",

schema = {
card = {
type = "integer",
required = true,
description = "0-based index of the card to toggle highlight",
},
},

requires_state = { G.STATES.SELECTING_HAND },

---@param args Request.Endpoint.Highlight.Params
---@param send_response fun(response: Response.Endpoint)
execute = function(args, send_response)
sendDebugMessage("Init highlight()", "BB.ENDPOINTS")

if not G.hand.cards[args.card + 1] then
send_response({
message = "Invalid card index: " .. args.card,
name = BB_ERROR_NAMES.BAD_REQUEST,
})
return
end

G.hand.cards[args.card + 1]:click()

sendDebugMessage("Return highlight() - toggled card " .. args.card, "BB.ENDPOINTS")
local state_data = BB_GAMESTATE.get_gamestate()
send_response(state_data)
end,
}
66 changes: 59 additions & 7 deletions src/lua/endpoints/pack.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
---@type BB_LOGGER
local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))()

-- Re-entrancy guard: prevents double-firing use_card when a previous
-- pack selection is still being processed (e.g. Black Hole animations).
local selection_in_progress = false

-- ==========================================================================
-- Pack Select Endpoint Params
-- ==========================================================================
Expand Down Expand Up @@ -110,6 +114,20 @@ return {
return
end

-- Block re-entrant calls while a previous selection is processing.
-- Skip bypasses the guard — it's the wedge-recovery path, and the skip
-- branch below clears selection_in_progress unconditionally before
-- calling skip_booster. Without this bypass a stuck guard (e.g. a
-- use_card error that never fires the completion event) wedges the
-- pack forever with no way for the bot to escape.
if selection_in_progress and not args.skip then
send_response({
message = "Pack selection already in progress",
name = BB_ERROR_NAMES.NOT_ALLOWED,
})
return
end

-- Validate pack_cards exists
if not G.pack_cards or G.pack_cards.REMOVED then
send_response({
Expand Down Expand Up @@ -239,7 +257,39 @@ return {

local pack_choices_before = G.GAME.pack_choices or 0

G.FUNCS.use_card(btn)
-- Pre-flight: defer to the game's own validation. Covers cases the
-- endpoint-level checks miss — e.g. Ectoplasm/Hex with zero editionless
-- jokers (card.lua:1798-1800), which otherwise crashes the game in a
-- delayed E_MANAGER event at card.lua:1734 trying to index a nil result
-- from pseudorandom_element({}).
if card.can_use_consumeable then
local usable_ok, usable = pcall(card.can_use_consumeable, card, true)
if usable_ok and usable == false then
send_response({
message = string.format(
"Card '%s' cannot be used in current state (game rejected via can_use_consumeable)",
card_key or card.ability and card.ability.name or "unknown"
),
name = BB_ERROR_NAMES.NOT_ALLOWED,
})
return true
end
end

selection_in_progress = true
-- Wrap use_card in pcall: if the game rejects the selection (e.g. Hex
-- with no editionless jokers, Ankh with no jokers), the completion
-- event never fires and the guard would latch on forever. pcall ensures
-- we surface the error and release the guard so the bot can recover.
local ok, err = pcall(G.FUNCS.use_card, btn)
if not ok then
selection_in_progress = false
send_response({
message = "use_card failed: " .. tostring(err),
name = BB_ERROR_NAMES.INVALID_STATE,
})
return true
end

-- Wait for action to complete - check pack_choices to determine expected state
G.E_MANAGER:add_event(Event({
Expand All @@ -255,6 +305,7 @@ return {
and G.STATE == G.STATES.SMODS_BOOSTER_OPENED

if pack_stable then
selection_in_progress = false
sendDebugMessage("Return pack() after selection (more choices remain)", "BB.ENDPOINTS")
send_response(BB_GAMESTATE.get_gamestate())
return true
Expand All @@ -265,10 +316,12 @@ return {
local back_to_shop = G.STATE == G.STATES.SHOP

if pack_closed and back_to_shop then
selection_in_progress = false
sendDebugMessage("Return pack() after selection", "BB.ENDPOINTS")
send_response(BB_GAMESTATE.get_gamestate())
return true
end

end
return false
end,
Expand All @@ -279,6 +332,7 @@ return {

-- Handle skip
if args.skip then
selection_in_progress = false -- Clear guard so skip can proceed after stuck selection
local pack_count = G.pack_cards.config and G.pack_cards.config.card_count or 0
sendDebugMessage(string.format("Pack: skipping (%d cards remaining)", pack_count), "BB.ENDPOINTS")
G.FUNCS.skip_booster({})
Expand All @@ -304,12 +358,10 @@ return {
end

-- Wait for hand cards to load for Arcana and Spectral packs
local pack_key = G.pack_cards
and G.pack_cards.cards
and G.pack_cards.cards[1]
and G.pack_cards.cards[1].ability
and G.pack_cards.cards[1].ability.set
local needs_hand = pack_key == "Tarot" or pack_key == "Spectral"
-- Check if hand cards are dealt (Arcana/Spectral packs deal hand cards).
-- Don't infer pack type from the first card's set — Black Hole is
-- set=Spectral but appears in Celestial packs, causing a false match.
local needs_hand = G.hand and G.hand.cards and #G.hand.cards > 0

if needs_hand then
-- Wait for hand cards to be fully loaded and positioned
Expand Down
14 changes: 6 additions & 8 deletions src/lua/endpoints/play.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
---@type BB_LOGGER
local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))()

-- Win overlay state is now tracked in BB_GAMESTATE (shared with love.update)

-- ==========================================================================
-- Play Endpoint Params
-- ==========================================================================
Expand Down Expand Up @@ -92,8 +94,6 @@ return {
G.E_MANAGER:add_event(Event({
trigger = "condition",
blocking = false,
blockable = false,
created_on_pause = true,
func = function()
-- State progression:
-- Loss: HAND_PLAYED -> NEW_ROUND -> (game paused) -> GAME_OVER
Expand Down Expand Up @@ -121,12 +121,10 @@ return {
return false
end

-- Game is won
if G.GAME.won then
sendDebugMessage("Return play() - won", "BB.ENDPOINTS")
local state_data = BB_GAMESTATE.get_gamestate()
send_response(state_data)
return true
-- Game is won — love.update auto-dismisses the overlay.
-- Wait here until that's done before looking for cash_out button.
if G.GAME.won and not BB_GAMESTATE.win_overlay_dismissed then
return false
end

-- Wait for first scoring row (blind1) to be added to the UI
Expand Down
17 changes: 16 additions & 1 deletion src/lua/endpoints/rearrange.lua
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,10 @@ return {
G.consumeables.cards = new_array
end

-- Update order fields on each card
-- Update order fields, rank, and visual positions on each card
for i, card in ipairs(new_array) do
-- Set rank so align_cards() respects our ordering
card.rank = i
if rearrange_type == "hand" then
card.config.card.order = i
if card.config.center then
Expand All @@ -202,6 +204,19 @@ return {
end
end

-- Trigger visual re-layout (mirrors gamepad d-pad reordering in controller.lua)
local area
if rearrange_type == "hand" then
area = G.hand
elseif rearrange_type == "jokers" then
area = G.jokers
else
area = G.consumeables
end
table.sort(area.cards, function(a, b) return a.rank < b.rank end)
area:set_ranks()
area:align_cards()

-- Wait for completion: state should remain stable after rearranging
G.E_MANAGER:add_event(Event({
trigger = "condition",
Expand Down
Loading