Skip to content

Commit b6c3e79

Browse files
DrLatBCclaude
andcommitted
Fix reliability bugs: win detection, sell, editions, re-entrancy, blind override
A collection of reliability and correctness fixes developed while running the balatrobot framework against a headless bot that plays thousands of rounds unattended. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c37a6b4 commit b6c3e79

9 files changed

Lines changed: 346 additions & 44 deletions

File tree

src/lua/endpoints/buy.lua

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -238,28 +238,49 @@ return {
238238
end
239239
elseif args.pack then
240240
local money_deducted = (G.GAME.dollars == initial_money - card.cost.buy)
241-
local pack_ready = (
242-
G.pack_cards
243-
and not G.pack_cards.REMOVED
244-
and G.pack_cards.cards[1]
245-
and G.STATE_COMPLETE
246-
and G.STATE == G.STATES.SMODS_BOOSTER_OPENED
247-
)
241+
local has_pack = G.pack_cards and not G.pack_cards.REMOVED and G.pack_cards.cards and G.pack_cards.cards[1]
242+
local state_ok = G.STATE_COMPLETE and G.STATE == G.STATES.SMODS_BOOSTER_OPENED
243+
local pack_ready = has_pack and state_ok
244+
245+
if not pack_ready then
246+
-- Debug: log what's blocking
247+
local state_name = "?"
248+
if G.STATES then
249+
for name, value in pairs(G.STATES) do
250+
if value == G.STATE then state_name = name break end
251+
end
252+
end
253+
sendDebugMessage(string.format(
254+
"buy(pack) waiting: money_ok=%s pack_exists=%s state=%s state_complete=%s",
255+
tostring(money_deducted), tostring(has_pack ~= nil), state_name, tostring(G.STATE_COMPLETE)
256+
), "BB.ENDPOINTS")
257+
end
258+
248259
if money_deducted and pack_ready then
249-
-- Check if this pack type needs hand (Arcana/Spectral packs)
250-
local pack_key = G.pack_cards.cards[1].ability and G.pack_cards.cards[1].ability.set
251-
local needs_hand = pack_key == "Tarot" or pack_key == "Spectral"
260+
-- Check if this pack type needs hand (Arcana/Spectral packs deal hand cards)
261+
-- Don't infer pack type from the first card's set — Black Hole is
262+
-- set=Spectral but appears in Celestial packs, causing a false match.
263+
local needs_hand = G.hand and G.hand.cards and #G.hand.cards > 0
252264

253265
if needs_hand then
254266
-- Wait for hand to be fully loaded and positioned
255267
local hand_limit = G.hand and G.hand.config and G.hand.config.card_limit or 8
268+
local deck_size = G.deck and G.deck.config and G.deck.config.card_count or 52
269+
local expected = math.min(deck_size, hand_limit)
270+
local hand_count = G.hand and G.hand.cards and #G.hand.cards or 0
256271
local hand_ready = G.hand
257272
and not G.hand.REMOVED
258273
and G.hand.cards
259-
and #G.hand.cards == hand_limit
274+
and hand_count >= expected
260275
and G.hand.T
261276
and G.hand.T.x
262277
local cards_positioned = hand_ready and G.hand.cards[1] and G.hand.cards[1].T and G.hand.cards[1].T.x
278+
if not done and money_deducted then
279+
sendDebugMessage(string.format(
280+
"buy(pack) hand wait: count=%d expected=%d limit=%d deck=%d positioned=%s",
281+
hand_count, expected, hand_limit, deck_size, tostring(cards_positioned ~= nil)
282+
), "BB.ENDPOINTS")
283+
end
263284
done = hand_ready and cards_positioned
264285
else
265286
done = true

src/lua/endpoints/discard.lua

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,6 @@ return {
9797
G.E_MANAGER:add_event(Event({
9898
trigger = "immediate",
9999
blocking = false,
100-
blockable = false,
101-
created_on_pause = true,
102100
func = function()
103101
-- State progression for discard:
104102
-- Discard always continues current round: HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND

src/lua/endpoints/pack.lua

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
---@type BB_LOGGER
44
local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))()
55

6+
-- Re-entrancy guard: prevents double-firing use_card when a previous
7+
-- pack selection is still being processed (e.g. Black Hole animations).
8+
local selection_in_progress = false
9+
610
-- ==========================================================================
711
-- Pack Select Endpoint Params
812
-- ==========================================================================
@@ -110,6 +114,15 @@ return {
110114
return
111115
end
112116

117+
-- Block re-entrant calls while a previous selection is processing
118+
if selection_in_progress then
119+
send_response({
120+
message = "Pack selection already in progress",
121+
name = BB_ERROR_NAMES.NOT_ALLOWED,
122+
})
123+
return
124+
end
125+
113126
-- Validate pack_cards exists
114127
if not G.pack_cards or G.pack_cards.REMOVED then
115128
send_response({
@@ -239,6 +252,7 @@ return {
239252

240253
local pack_choices_before = G.GAME.pack_choices or 0
241254

255+
selection_in_progress = true
242256
G.FUNCS.use_card(btn)
243257

244258
-- Wait for action to complete - check pack_choices to determine expected state
@@ -255,6 +269,7 @@ return {
255269
and G.STATE == G.STATES.SMODS_BOOSTER_OPENED
256270

257271
if pack_stable then
272+
selection_in_progress = false
258273
sendDebugMessage("Return pack() after selection (more choices remain)", "BB.ENDPOINTS")
259274
send_response(BB_GAMESTATE.get_gamestate())
260275
return true
@@ -265,10 +280,24 @@ return {
265280
local back_to_shop = G.STATE == G.STATES.SHOP
266281

267282
if pack_closed and back_to_shop then
283+
selection_in_progress = false
268284
sendDebugMessage("Return pack() after selection", "BB.ENDPOINTS")
269285
send_response(BB_GAMESTATE.get_gamestate())
270286
return true
271287
end
288+
289+
-- Debug: log what's blocking
290+
local state_name = "?"
291+
if G.STATES then
292+
for name, value in pairs(G.STATES) do
293+
if value == G.STATE then state_name = name break end
294+
end
295+
end
296+
sendDebugMessage(string.format(
297+
"pack() wait: closed=%s state=%s complete=%s choices=%s won=%s",
298+
tostring(pack_closed), state_name, tostring(G.STATE_COMPLETE),
299+
tostring(G.GAME.pack_choices), tostring(G.GAME.won)
300+
), "BB.ENDPOINTS")
272301
end
273302
return false
274303
end,
@@ -279,6 +308,7 @@ return {
279308

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

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

314342
if needs_hand then
315343
-- Wait for hand cards to be fully loaded and positioned

src/lua/endpoints/play.lua

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
---@type BB_LOGGER
44
local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))()
55

6+
-- Win overlay state is now tracked in BB_GAMESTATE (shared with love.update)
7+
68
-- ==========================================================================
79
-- Play Endpoint Params
810
-- ==========================================================================
@@ -92,8 +94,6 @@ return {
9294
G.E_MANAGER:add_event(Event({
9395
trigger = "condition",
9496
blocking = false,
95-
blockable = false,
96-
created_on_pause = true,
9797
func = function()
9898
-- State progression:
9999
-- Loss: HAND_PLAYED -> NEW_ROUND -> (game paused) -> GAME_OVER
@@ -121,12 +121,10 @@ return {
121121
return false
122122
end
123123

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

132130
-- Wait for first scoring row (blind1) to be added to the UI

src/lua/endpoints/rearrange.lua

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,10 @@ return {
185185
G.consumeables.cards = new_array
186186
end
187187

188-
-- Update order fields on each card
188+
-- Update order fields, rank, and visual positions on each card
189189
for i, card in ipairs(new_array) do
190+
-- Set rank so align_cards() respects our ordering
191+
card.rank = i
190192
if rearrange_type == "hand" then
191193
card.config.card.order = i
192194
if card.config.center then
@@ -202,6 +204,19 @@ return {
202204
end
203205
end
204206

207+
-- Trigger visual re-layout (mirrors gamepad d-pad reordering in controller.lua)
208+
local area
209+
if rearrange_type == "hand" then
210+
area = G.hand
211+
elseif rearrange_type == "jokers" then
212+
area = G.jokers
213+
else
214+
area = G.consumeables
215+
end
216+
table.sort(area.cards, function(a, b) return a.rank < b.rank end)
217+
area:set_ranks()
218+
area:align_cards()
219+
205220
-- Wait for completion: state should remain stable after rearranging
206221
G.E_MANAGER:add_event(Event({
207222
trigger = "condition",

src/lua/endpoints/sell.lua

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,13 @@ return {
121121
trigger = "condition",
122122
blocking = false,
123123
func = function()
124-
-- Check all 5 completion criteria
125124
local current_area = sell_type == "joker" and G.jokers or G.consumeables
126125
local current_array = current_area.cards
127126

128-
-- 1. Card count decreased by 1
129-
local count_decreased = (current_area.config.card_count == initial_count - 1)
130-
131-
-- 2. Money increased by sell_cost
127+
-- 1. Money increased by sell_cost
132128
local money_increased = (G.GAME.dollars == expected_money)
133129

134-
-- 3. Card no longer exists (verify by unique_val)
130+
-- 2. Card no longer exists (by sort_id)
135131
local card_gone = true
136132
for _, c in ipairs(current_array) do
137133
if c.sort_id == card_id then
@@ -140,14 +136,16 @@ return {
140136
end
141137
end
142138

143-
-- 4. State stability
139+
-- 3. State stability
144140
local state_stable = G.STATE_COMPLETE == true
145141

146-
-- 5. Still in valid state
142+
-- 4. Still in valid state
147143
local valid_state = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND)
148144

149-
-- All conditions must be met
150-
if count_decreased and money_increased and card_gone and state_stable and valid_state then
145+
-- Note: card count is NOT checked here — some jokers (e.g. Invisible Joker)
146+
-- spawn a replacement on sell, leaving count unchanged. card_gone + money_increased
147+
-- uniquely identify completion without relying on count.
148+
if money_increased and card_gone and state_stable and valid_state then
151149
sendDebugMessage("Return sell()", "BB.ENDPOINTS")
152150
send_response(BB_GAMESTATE.get_gamestate())
153151
return true

src/lua/endpoints/set.lua

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
---@field hands integer? New number of hands left number
1313
---@field discards integer? New number of discards left number
1414
---@field shop boolean? Re-stock shop with new items
15+
---@field blind string? Boss blind key (e.g. "bl_flint") — sets the upcoming boss blind
1516

1617
-- ==========================================================================
1718
-- Set Endpoint
@@ -60,6 +61,11 @@ return {
6061
required = false,
6162
description = "Re-stock shop with new items",
6263
},
64+
blind = {
65+
type = "string",
66+
required = false,
67+
description = "Boss blind key (e.g. 'bl_flint') — sets the upcoming boss blind",
68+
},
6369
},
6470

6571
requires_state = nil,
@@ -87,6 +93,7 @@ return {
8793
and args.hands == nil
8894
and args.discards == nil
8995
and args.shop == nil
96+
and args.blind == nil
9097
then
9198
send_response({
9299
message = "Must provide at least one field to set",
@@ -167,6 +174,19 @@ return {
167174
G.GAME.current_round.discards_left = args.discards
168175
end
169176

177+
-- Set boss blind
178+
if args.blind then
179+
if not G.P_BLINDS[args.blind] then
180+
send_response({
181+
message = "Unknown blind key: " .. tostring(args.blind),
182+
name = BB_ERROR_NAMES.BAD_REQUEST,
183+
})
184+
return
185+
end
186+
G.GAME.round_resets.blind_choices.Boss = args.blind
187+
G.RESET_BLIND_STATES = true
188+
end
189+
170190
if args.shop then
171191
if G.STATE ~= G.STATES.SHOP then
172192
send_response({

src/lua/endpoints/start.lua

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,16 @@ return {
102102
return
103103
end
104104

105+
-- Reset win overlay flags from previous run
106+
BB_GAMESTATE.win_overlay_dismissed = false
107+
BB_GAMESTATE.win_overlay_dismissing = false
108+
105109
-- Reset the game (setup_run and exit_overlay_menu)
106-
G.FUNCS.setup_run({ config = {} })
107-
G.FUNCS.exit_overlay_menu()
110+
-- Use pcall to handle cases where the overlay isn't fully ready
111+
pcall(function()
112+
G.FUNCS.setup_run({ config = {} })
113+
G.FUNCS.exit_overlay_menu()
114+
end)
108115

109116
-- Find and set the deck using the mapped deck name
110117
local deck_found = false
@@ -144,7 +151,21 @@ return {
144151
.. tostring(args.seed or "none"),
145152
"BB.ENDPOINTS"
146153
)
147-
G.FUNCS.start_run(nil, run_params)
154+
local ok, err = pcall(G.FUNCS.start_run, nil, run_params)
155+
if not ok then
156+
sendDebugMessage("start_run failed: " .. tostring(err) .. " — retrying after delete_run", "BB.ENDPOINTS")
157+
-- Clean up stale state and retry
158+
pcall(function() G:delete_run() end)
159+
local ok2, err2 = pcall(G.FUNCS.start_run, nil, run_params)
160+
if not ok2 then
161+
sendDebugMessage("start_run retry also failed: " .. tostring(err2), "BB.ENDPOINTS")
162+
send_response({
163+
message = "Failed to start run: " .. tostring(err2),
164+
name = BB_ERROR_NAMES.INTERNAL_ERROR,
165+
})
166+
return
167+
end
168+
end
148169

149170
-- Wait for run to start using Balatro's Event Manager
150171
G.E_MANAGER:add_event(Event({

0 commit comments

Comments
 (0)