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
+ 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" },