diff --git a/balatrobot.json b/balatrobot.json
index 829121fa..17513ae1 100644
--- a/balatrobot.json
+++ b/balatrobot.json
@@ -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)"
]
}
diff --git a/balatrobot.lua b/balatrobot.lua
index 90b3a54f..4119e3e0 100644
--- a/balatrobot.lua
+++ b/balatrobot.lua
@@ -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
@@ -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()
@@ -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
diff --git a/docs/index.md b/docs/index.md
index 03af7a41..c6955690 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -13,7 +13,7 @@
- BalatroBot
+ BalatroBot
API for developing Balatro bots
diff --git a/docs/installation.md b/docs/installation.md
index 781b18a4..7b14edd6 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -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
diff --git a/lovely/seed.toml b/lovely/seed.toml
new file mode 100644
index 00000000..aa29c454
--- /dev/null
+++ b/lovely/seed.toml
@@ -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)"
diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua
index 77e42963..63e37dfd 100644
--- a/src/lua/endpoints/buy.lua
+++ b/src/lua/endpoints/buy.lua
@@ -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
diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua
index 77316976..22d2f6e7 100644
--- a/src/lua/endpoints/discard.lua
+++ b/src/lua/endpoints/discard.lua
@@ -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
diff --git a/src/lua/endpoints/highlight.lua b/src/lua/endpoints/highlight.lua
new file mode 100644
index 00000000..7f98b235
--- /dev/null
+++ b/src/lua/endpoints/highlight.lua
@@ -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,
+}
diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua
index d60efa80..e94d523d 100644
--- a/src/lua/endpoints/pack.lua
+++ b/src/lua/endpoints/pack.lua
@@ -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
-- ==========================================================================
@@ -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({
@@ -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({
@@ -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
@@ -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,
@@ -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({})
@@ -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
diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua
index 1b9f0a98..2c63b56f 100644
--- a/src/lua/endpoints/play.lua
+++ b/src/lua/endpoints/play.lua
@@ -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
-- ==========================================================================
@@ -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
@@ -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
diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua
index e8ec143f..bcf21ac6 100644
--- a/src/lua/endpoints/rearrange.lua
+++ b/src/lua/endpoints/rearrange.lua
@@ -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
@@ -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",
diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua
index 5d112222..31ef538b 100644
--- a/src/lua/endpoints/sell.lua
+++ b/src/lua/endpoints/sell.lua
@@ -121,17 +121,13 @@ return {
trigger = "condition",
blocking = false,
func = function()
- -- Check all 5 completion criteria
local current_area = sell_type == "joker" and G.jokers or G.consumeables
local current_array = current_area.cards
- -- 1. Card count decreased by 1
- local count_decreased = (current_area.config.card_count == initial_count - 1)
-
- -- 2. Money increased by sell_cost
+ -- 1. Money increased by sell_cost
local money_increased = (G.GAME.dollars == expected_money)
- -- 3. Card no longer exists (verify by unique_val)
+ -- 2. Card no longer exists (by sort_id)
local card_gone = true
for _, c in ipairs(current_array) do
if c.sort_id == card_id then
@@ -140,14 +136,16 @@ return {
end
end
- -- 4. State stability
+ -- 3. State stability
local state_stable = G.STATE_COMPLETE == true
- -- 5. Still in valid state
+ -- 4. Still in valid state
local valid_state = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND)
- -- All conditions must be met
- if count_decreased and money_increased and card_gone and state_stable and valid_state then
+ -- Note: card count is NOT checked here — some jokers (e.g. Invisible Joker)
+ -- spawn a replacement on sell, leaving count unchanged. card_gone + money_increased
+ -- uniquely identify completion without relying on count.
+ if money_increased and card_gone and state_stable and valid_state then
sendDebugMessage("Return sell()", "BB.ENDPOINTS")
send_response(BB_GAMESTATE.get_gamestate())
return true
diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua
index 5885d455..a95bf321 100644
--- a/src/lua/endpoints/set.lua
+++ b/src/lua/endpoints/set.lua
@@ -12,6 +12,8 @@
---@field hands integer? New number of hands left number
---@field discards integer? New number of discards left number
---@field shop boolean? Re-stock shop with new items
+---@field blind string? Boss blind key (e.g. "bl_flint") — sets the upcoming boss blind
+---@field debuff boolean? Re-apply blind debuffs to all hand cards (useful after add())
-- ==========================================================================
-- Set Endpoint
@@ -60,6 +62,16 @@ return {
required = false,
description = "Re-stock shop with new items",
},
+ blind = {
+ type = "string",
+ required = false,
+ description = "Boss blind key (e.g. 'bl_flint') — sets the upcoming boss blind",
+ },
+ debuff = {
+ type = "boolean",
+ required = false,
+ description = "Re-apply blind debuffs to all hand cards (call after add() during a boss blind)",
+ },
},
requires_state = nil,
@@ -87,6 +99,8 @@ return {
and args.hands == nil
and args.discards == nil
and args.shop == nil
+ and args.blind == nil
+ and args.debuff == nil
then
send_response({
message = "Must provide at least one field to set",
@@ -167,6 +181,47 @@ return {
G.GAME.current_round.discards_left = args.discards
end
+ -- Set boss blind
+ if args.blind then
+ if not G.P_BLINDS[args.blind] then
+ send_response({
+ message = "Unknown blind key: " .. tostring(args.blind),
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ G.GAME.round_resets.blind_choices.Boss = args.blind
+ G.RESET_BLIND_STATES = true
+
+ -- Rebuild the boss blind UIBox so select_blind picks up the new blind.
+ -- Mirrors the logic in G.FUNCS.reroll_boss (button_callbacks.lua).
+ if G.blind_select_opts and G.blind_select_opts.boss then
+ local par = G.blind_select_opts.boss.parent
+ G.blind_select_opts.boss:remove()
+ G.blind_select_opts.boss = UIBox{
+ T = {par.T.x, 0, 0, 0},
+ definition =
+ {n=G.UIT.ROOT, config={align = "cm", colour = G.C.CLEAR}, nodes={
+ UIBox_dyn_container(
+ {create_UIBox_blind_choice('Boss')},
+ false,
+ get_blind_main_colour('Boss'),
+ mix_colours(G.C.BLACK, get_blind_main_colour('Boss'), 0.8)
+ )
+ }},
+ config = {align="bmi",
+ offset = {x=0, y=G.ROOM.T.y + 9},
+ major = par,
+ xy_bond = 'Weak'
+ }
+ }
+ par.config.object = G.blind_select_opts.boss
+ par.config.object:recalculate()
+ G.blind_select_opts.boss.parent = par
+ G.blind_select_opts.boss.alignment.offset.y = 0
+ end
+ end
+
if args.shop then
if G.STATE ~= G.STATES.SHOP then
send_response({
@@ -188,6 +243,23 @@ return {
G:update_shop()
end
+ -- Re-apply blind debuffs to hand cards
+ if args.debuff then
+ if G.STATE ~= G.STATES.SELECTING_HAND then
+ send_response({
+ message = "Can only apply debuffs in SELECTING_HAND state",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+ if G.GAME.blind and G.GAME.blind.debuff_card then
+ for _, card in ipairs(G.hand.cards) do
+ G.GAME.blind:debuff_card(card)
+ end
+ sendDebugMessage("Re-applied blind debuffs to " .. #G.hand.cards .. " hand cards", "BB.ENDPOINTS")
+ end
+ end
+
G.E_MANAGER:add_event(Event({
trigger = "condition",
blocking = false,
diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua
index 5bcefa62..e9bb9d29 100644
--- a/src/lua/endpoints/start.lua
+++ b/src/lua/endpoints/start.lua
@@ -102,9 +102,16 @@ return {
return
end
+ -- Reset win overlay flags from previous run
+ BB_GAMESTATE.win_overlay_dismissed = false
+ BB_GAMESTATE.win_overlay_dismissing = false
+
-- Reset the game (setup_run and exit_overlay_menu)
- G.FUNCS.setup_run({ config = {} })
- G.FUNCS.exit_overlay_menu()
+ -- Use pcall to handle cases where the overlay isn't fully ready
+ pcall(function()
+ G.FUNCS.setup_run({ config = {} })
+ G.FUNCS.exit_overlay_menu()
+ end)
-- Find and set the deck using the mapped deck name
local deck_found = false
@@ -144,7 +151,21 @@ return {
.. tostring(args.seed or "none"),
"BB.ENDPOINTS"
)
- G.FUNCS.start_run(nil, run_params)
+ local ok, err = pcall(G.FUNCS.start_run, nil, run_params)
+ if not ok then
+ sendDebugMessage("start_run failed: " .. tostring(err) .. " — retrying after delete_run", "BB.ENDPOINTS")
+ -- Clean up stale state and retry
+ pcall(function() G:delete_run() end)
+ local ok2, err2 = pcall(G.FUNCS.start_run, nil, run_params)
+ if not ok2 then
+ sendDebugMessage("start_run retry also failed: " .. tostring(err2), "BB.ENDPOINTS")
+ send_response({
+ message = "Failed to start run: " .. tostring(err2),
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+ end
-- Wait for run to start using Balatro's Event Manager
G.E_MANAGER:add_event(Event({
diff --git a/src/lua/utils/earnings.lua b/src/lua/utils/earnings.lua
new file mode 100644
index 00000000..ba922ef1
--- /dev/null
+++ b/src/lua/utils/earnings.lua
@@ -0,0 +1,185 @@
+---Per-game money earnings tracker.
+---
+---Captures every dollar earned during a run, attributed to its source
+---(joker / tag / interest / hands / discards / blind / playing-card).
+---Stored on G.GAME.jackpotts_earnings for the gamestate serializer to expose.
+---
+---Hooks three sites:
+--- 1. add_round_eval_row — round-end income (jokers via calc_dollar_bonus,
+--- tags, interest, hand/discard money, blind reward).
+--- Skips name=='bottom' which is the Balatro round-summary total
+--- (would double-count every other row).
+--- 2. Card:get_p_dollars — per-played-card income (Lucky, Gold seal, Gold
+--- enhancement) accumulated during scoring.
+--- 3. Card:calculate_joker + ease_dollars — mid-round joker triggers
+--- (Faceless, Rough Gem, Business, Reserved Parking, etc.). The
+--- calculate_joker wrapper pushes the joker onto a stack before its
+--- effect runs; the ease_dollars wrapper attributes the $ to whatever
+--- joker is on top of the stack. Outside any joker scope, ease_dollars
+--- is ignored (the $ is captured by another path or is non-earnings,
+--- e.g. shop sells/buys, rentals).
+
+local earnings = {}
+
+local function ensure_store()
+ if not G or not G.GAME then return nil end
+ if not G.GAME.jackpotts_earnings then
+ G.GAME.jackpotts_earnings = { entries = {}, next_id = 1 }
+ end
+ return G.GAME.jackpotts_earnings
+end
+
+local function current_round_num()
+ if G and G.GAME and G.GAME.round then return G.GAME.round end
+ return 0
+end
+
+local function current_ante()
+ if G and G.GAME and G.GAME.round_resets and G.GAME.round_resets.ante then
+ return G.GAME.round_resets.ante
+ end
+ return 0
+end
+
+local function record(entry)
+ local store = ensure_store()
+ if not store then return end
+ entry.id = store.next_id
+ entry.round = entry.round or current_round_num()
+ entry.ante = entry.ante or current_ante()
+ store.next_id = store.next_id + 1
+ table.insert(store.entries, entry)
+end
+
+local function joker_key(card)
+ if card and card.config and card.config.center and card.config.center.key then
+ return card.config.center.key
+ end
+ return nil
+end
+
+local function tag_key(tag)
+ if tag and tag.key then return tag.key end
+ return nil
+end
+
+---Map add_round_eval_row's `name` field to a stable source category.
+---Names like "joker1", "joker2", "tag1", "tag2" become "joker"/"tag";
+---"hands"/"discards"/"interest"/"blind1" pass through trimmed.
+local function classify_eval_row(name)
+ if not name then return "unknown" end
+ if name:match("^joker%d*$") then return "joker" end
+ if name:match("^tag%d*$") then return "tag" end
+ if name == "interest" then return "interest" end
+ if name == "hands" then return "hands" end
+ if name == "discards" then return "discards" end
+ if name:match("^blind") then return "blind" end
+ return name
+end
+
+function earnings.install()
+ if earnings._installed then return end
+
+ -- 1. add_round_eval_row — round-end income
+ -- Skip name=='bottom' (the round-summary total row that sums all the
+ -- joker/blind/interest/hands rows above it — recording it would
+ -- double-count every other source).
+ if type(add_round_eval_row) == "function" then
+ local _orig = add_round_eval_row
+ add_round_eval_row = function(args) ---@diagnostic disable-line: duplicate-set-field
+ args = args or {}
+ local dollars = args.dollars or 0
+ if dollars ~= 0 and not args.saved and args.name ~= "bottom" then
+ record({
+ source = classify_eval_row(args.name),
+ raw_name = args.name,
+ dollars = dollars,
+ joker_key = joker_key(args.card),
+ tag_key = tag_key(args.tag),
+ phase = "round_eval",
+ })
+ end
+ return _orig(args)
+ end
+ end
+
+ -- 2. Card:get_p_dollars — per-card scoring income (Lucky, Gold seal,
+ -- Gold enhancement). Hook returns the dollar amount.
+ if Card and Card.get_p_dollars then
+ local _orig = Card.get_p_dollars
+ Card.get_p_dollars = function(self) ---@diagnostic disable-line: duplicate-set-field
+ local ret = _orig(self)
+ if ret and ret ~= 0 then
+ local enh = self.config and self.config.center and self.config.center.key
+ local seal = self.seal
+ local source = "card"
+ if self.lucky_trigger then
+ source = "lucky"
+ elseif seal == "Gold" then
+ source = "gold_seal"
+ elseif enh == "m_gold" then
+ source = "gold_enhancement"
+ end
+ record({
+ source = source,
+ dollars = ret,
+ enhancement = enh,
+ seal = seal,
+ rank = self.base and self.base.value,
+ suit = self.base and self.base.suit,
+ phase = "scoring",
+ })
+ end
+ return ret
+ end
+ end
+
+ -- 3. Card:calculate_joker + ease_dollars — mid-round joker triggers.
+ -- Faceless, Rough Gem, Business, Reserved Parking, Trading Card,
+ -- To the Moon-style payouts all call ease_dollars() directly inside
+ -- their joker calculate function — they do NOT route through
+ -- card_eval_status_text. To attribute the $ to the right joker we
+ -- push self onto a stack at the start of calculate_joker and pop
+ -- when it returns; ease_dollars then credits the joker on top of
+ -- the stack. ease_dollars calls outside any joker scope (rentals,
+ -- shop sells/buys, blind reward) are ignored — they're either
+ -- captured elsewhere (round_eval path) or aren't earnings.
+ earnings._joker_stack = {}
+ if Card and Card.calculate_joker then
+ local _orig = Card.calculate_joker
+ Card.calculate_joker = function(self, context) ---@diagnostic disable-line: duplicate-set-field
+ table.insert(earnings._joker_stack, self)
+ local ok, a, b = pcall(_orig, self, context)
+ table.remove(earnings._joker_stack)
+ if not ok then error(a) end
+ return a, b
+ end
+ end
+ if type(ease_dollars) == "function" then
+ local _orig = ease_dollars
+ ease_dollars = function(mod, instant) ---@diagnostic disable-line: duplicate-set-field
+ local stack = earnings._joker_stack
+ local top = stack[#stack]
+ if top and mod and mod ~= 0 then
+ record({
+ source = "joker_trigger",
+ dollars = mod,
+ joker_key = joker_key(top),
+ phase = "play",
+ })
+ end
+ return _orig(mod, instant)
+ end
+ end
+
+ earnings._installed = true
+end
+
+---Reset accumulator (called at game start). Safe to call when G.GAME absent.
+function earnings.reset()
+ if G and G.GAME then
+ G.GAME.jackpotts_earnings = { entries = {}, next_id = 1 }
+ end
+end
+
+return earnings
diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua
index a7cc2b97..7572cced 100644
--- a/src/lua/utils/gamestate.lua
+++ b/src/lua/utils/gamestate.lua
@@ -232,14 +232,85 @@ local function extract_card_modifier(card)
modifier.seal = string.upper(card.seal)
end
- -- Edition (table with type/key)
- if card.edition and card.edition.type then
- modifier.edition = string.upper(card.edition.type)
+ -- Edition: use the card's own scoring methods for reliable detection.
+ -- card:get_chip_mult() returns the holo mult, get_chip_bonus() includes foil chips,
+ -- get_chip_x_mult() returns polychrome xmult. These work regardless of how
+ -- the edition table is structured internally.
+ if card.edition then
+ -- Type string for identification
+ if card.edition.type then
+ modifier.edition = string.upper(card.edition.type)
+ elseif card.edition.holo then
+ modifier.edition = "HOLO"
+ elseif card.edition.foil then
+ modifier.edition = "FOIL"
+ elseif card.edition.polychrome then
+ modifier.edition = "POLYCHROME"
+ elseif card.edition.negative then
+ modifier.edition = "NEGATIVE"
+ elseif card.edition.key then
+ -- SMODS: edition.key = "e_holo" / "e_foil" / "e_polychrome" / "e_negative"
+ local etype = card.edition.key:gsub("^e_", "")
+ modifier.edition = string.upper(etype)
+ end
+
+ -- Numeric scoring values
+ if card.edition.mult and card.edition.mult ~= 0 then
+ modifier.edition_mult = card.edition.mult
+ end
+ if card.edition.chips and card.edition.chips ~= 0 then
+ modifier.edition_chips = card.edition.chips
+ end
+ -- Use get_edition() for x_mult: the raw card.edition.x_mult can be
+ -- contaminated by enhancement values (Glass x2 overwrites Polychrome x1.5).
+ -- get_edition() returns the correct edition-only value.
+ if card.get_edition then
+ local ok, ed = pcall(function() return card:get_edition() end)
+ if ok and ed and ed.x_mult_mod and ed.x_mult_mod ~= 0 then
+ modifier.edition_x_mult = ed.x_mult_mod
+ end
+ elseif card.edition.x_mult and card.edition.x_mult ~= 0 then
+ modifier.edition_x_mult = card.edition.x_mult
+ end
+ end
+ -- Fallback: use card scoring methods directly (catches editions not in card.edition table)
+ -- Skip if the card has a MULT or LUCKY enhancement — get_chip_mult() includes
+ -- enhancement mult, which would create a phantom HOLO edition.
+ local has_mult_enhancement = card.ability and card.ability.effect
+ and (card.ability.effect == "Mult Card" or card.ability.effect == "Lucky Card")
+ if not modifier.edition and card.get_chip_mult and not has_mult_enhancement then
+ local ok, emult = pcall(function() return card:get_chip_mult() end)
+ if ok and emult and emult > 0 then
+ modifier.edition = "HOLO"
+ modifier.edition_mult = emult
+ end
+ end
+ -- Note: removed get_chip_x_mult() fallback here — it returns the enhancement
+ -- x_mult (Glass 2.0), not the edition x_mult (Polychrome 1.5). Edition detection
+ -- via get_edition() above is now the primary path.
+ if not modifier.edition_chips and card.get_chip_bonus then
+ -- get_chip_bonus includes base nominal + ability.bonus + perma_bonus + edition chips
+ -- We already handle perma_bonus separately, so only check for foil-level chips
+ local ok, echips = pcall(function() return card:get_chip_bonus() end)
+ if ok and echips then
+ local base_nominal = (card.base and card.base.nominal) or 0
+ local ability_bonus = (card.ability and card.ability.bonus) or 0
+ local perma = (card.ability and card.ability.perma_bonus) or 0
+ local edition_chips = echips - base_nominal - ability_bonus - perma
+ if edition_chips > 0 then
+ modifier.edition = modifier.edition or "FOIL"
+ modifier.edition_chips = edition_chips
+ end
+ end
end
-- Enhancement (from ability.name for enhanced cards)
if card.ability and card.ability.effect and card.ability.effect ~= "Base" then
modifier.enhancement = string.upper(card.ability.effect:gsub(" Card", ""))
+ -- Expose enhancement x_mult separately (Glass = 2.0)
+ if card.ability.x_mult and card.ability.x_mult ~= 1 then
+ modifier.enhancement_x_mult = card.ability.x_mult
+ end
end
-- Eternal (boolean from ability)
@@ -279,6 +350,50 @@ local function extract_card_value(card)
-- Effect description (for all cards)
value.effect = get_card_ui_description(card)
+ -- Permanent chip bonus (from Hiker etc.) — only for playing cards
+ if card.ability then
+ if card.ability.perma_bonus and card.ability.perma_bonus ~= 0 then
+ value.perma_bonus = card.ability.perma_bonus
+ end
+ end
+
+ -- Joker rarity (1=Common, 2=Uncommon, 3=Rare, 4=Legendary)
+ if card.config and card.config.center and card.config.center.rarity then
+ value.rarity = card.config.center.rarity
+ end
+
+ -- Joker ability data: expose actual scoring values instead of requiring
+ -- text parsing. Includes accumulated values for scaling jokers.
+ if card.ability then
+ local ab = {}
+ -- extra: varies by joker — can be number or table with chips/mult/Xmult/etc.
+ if card.ability.extra ~= nil then
+ if type(card.ability.extra) == "table" then
+ -- Shallow copy the table
+ for k, v in pairs(card.ability.extra) do
+ if type(v) ~= "table" and type(v) ~= "function" then
+ ab[k] = v
+ end
+ end
+ else
+ ab.extra = card.ability.extra
+ end
+ end
+ -- Config-level scoring fields (hand-type jokers like Jolly, Sly, etc.)
+ if card.ability.t_mult and card.ability.t_mult ~= 0 then ab.t_mult = card.ability.t_mult end
+ if card.ability.t_chips and card.ability.t_chips ~= 0 then ab.t_chips = card.ability.t_chips end
+ if card.ability.mult and card.ability.mult ~= 0 then ab.mult = card.ability.mult end
+ if card.ability.x_mult and card.ability.x_mult ~= 0 then ab.x_mult = card.ability.x_mult end
+ -- Driver's License enhanced card count
+ if card.ability.driver_tally then ab.driver_tally = card.ability.driver_tally end
+ -- Loyalty Card: remaining hands until trigger
+ if card.ability.loyalty_remaining ~= nil then ab.loyalty_remaining = card.ability.loyalty_remaining end
+ -- Only include if non-empty
+ if next(ab) ~= nil then
+ value.ability = ab
+ end
+ end
+
return value
end
@@ -468,6 +583,30 @@ local function extract_round_info()
round.chips = G.GAME.chips
end
+ -- The Ox: which hand type triggers money loss
+ if G.GAME.current_round.most_played_poker_hand then
+ round.most_played_poker_hand = G.GAME.current_round.most_played_poker_hand
+ end
+
+ -- Ancient Joker's current rotating suit
+ if G.GAME.current_round.ancient_card and G.GAME.current_round.ancient_card.suit then
+ local suit = G.GAME.current_round.ancient_card.suit
+ local suit_map = {Spades = "S", Hearts = "H", Clubs = "C", Diamonds = "D"}
+ round.ancient_suit = suit_map[suit] or suit
+ end
+
+ -- The Idol: target rank+suit that grants X2 Mult, rerolled each round
+ if G.GAME.current_round.idol_card
+ and G.GAME.current_round.idol_card.rank
+ and G.GAME.current_round.idol_card.suit
+ then
+ local suit_map = { Spades = "S", Hearts = "H", Clubs = "C", Diamonds = "D" }
+ round.idol_card = {
+ rank = convert_rank_to_enum(G.GAME.current_round.idol_card.rank) or G.GAME.current_round.idol_card.rank,
+ suit = suit_map[G.GAME.current_round.idol_card.suit] or G.GAME.current_round.idol_card.suit,
+ }
+ end
+
return round
end
@@ -777,6 +916,27 @@ function gamestate.get_gamestate()
-- Blinds info
state_data.blinds = gamestate.get_blinds_info()
+
+ -- JackPotts earnings tracker (per-game money attribution)
+ if G.GAME.jackpotts_earnings and G.GAME.jackpotts_earnings.entries then
+ state_data.earnings = G.GAME.jackpotts_earnings.entries
+ end
+
+ -- Currently held tags (Investment, Handy, Top-up, Speed, Garbage, etc.).
+ -- Each entry: {key, name, ante (acquired)}.
+ if G.GAME.tags and #G.GAME.tags > 0 then
+ local tags_out = {}
+ for _, tag in ipairs(G.GAME.tags) do
+ local t_key = tag.key or ""
+ local t_info = (G.P_TAGS and G.P_TAGS[t_key]) or {}
+ table.insert(tags_out, {
+ key = t_key,
+ name = t_info.name or t_key,
+ ante = tag.ante,
+ })
+ end
+ state_data.tags = tags_out
+ end
end
-- Always available areas
@@ -824,6 +984,14 @@ end
-- normal event-based detection from working.
gamestate.on_game_over = nil
+-- Tracks whether we've already dismissed the win overlay for endless mode.
+-- The dismissal happens in love.update (check_win_overlay) because the
+-- overlay sets G.SETTINGS.paused=true which blocks event processing.
+-- Two-phase: dismiss first, then confirm removal on a later frame so the
+-- game has time to fully process the overlay removal before the bot resumes.
+gamestate.win_overlay_dismissed = false
+gamestate.win_overlay_dismissing = false
+
---Check and trigger GAME_OVER callback if state is GAME_OVER
---Called from love.update before game logic runs
function gamestate.check_game_over()
@@ -833,4 +1001,29 @@ function gamestate.check_game_over()
end
end
+---Auto-dismiss the "YOU WIN" overlay to continue into endless mode.
+---Called from love.update so it works even when the game is paused.
+---Two-phase: dismiss first, then confirm removal on a later frame so the
+---game has time to fully process the overlay removal before the bot resumes.
+function gamestate.check_win_overlay()
+ if gamestate.win_overlay_dismissed then return end
+ if not G.GAME or not G.GAME.won then return end
+
+ -- Phase 2: overlay was dismissed on a previous frame, confirm it's gone
+ if gamestate.win_overlay_dismissing then
+ if not G.OVERLAY_MENU then
+ sendDebugMessage("check_win_overlay() - overlay confirmed gone, resuming", "BB.GAMESTATE")
+ gamestate.win_overlay_dismissed = true
+ end
+ return
+ end
+
+ -- Phase 1: overlay is visible, dismiss it now
+ if not G.OVERLAY_MENU then return end -- not visible yet
+ sendDebugMessage("check_win_overlay() - dismissing win overlay for endless mode", "BB.GAMESTATE")
+ G.FUNCS.exit_overlay_menu()
+ gamestate.on_game_over = nil -- prevent GAME_OVER callback from firing
+ gamestate.win_overlay_dismissing = true
+end
+
return gamestate
diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json
index 32b789bf..23b32855 100644
--- a/src/lua/utils/openrpc.json
+++ b/src/lua/utils/openrpc.json
@@ -3,7 +3,7 @@
"info": {
"title": "BalatroBot API",
"description": "JSON-RPC 2.0 API for Balatro bot development. This API allows external clients to control the Balatro game, query game state, and execute actions through an HTTP server.",
- "version": "1.4.1",
+ "version": "1.4.0",
"license": {
"name": "MIT"
}
diff --git a/uv.lock b/uv.lock
index dfaa6cdc..e64f17c2 100644
--- a/uv.lock
+++ b/uv.lock
@@ -48,7 +48,7 @@ wheels = [
[[package]]
name = "balatrobot"
-version = "1.4.1"
+version = "1.4.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },