diff --git a/src/balatrobot/client.py b/src/balatrobot/client.py index e583070..c6a1ee7 100644 --- a/src/balatrobot/client.py +++ b/src/balatrobot/client.py @@ -474,10 +474,18 @@ def screenshot(self, path: Path | None = None) -> Path: """ screenshot_response = self.send_message("screenshot", {}) + # TODO: DEJANK + screenshot_response_path = Path( + str(screenshot_response["path"]).replace( + "C:", + "/home/stephen/.steam/steam/steamapps/compatdata/2379780/pfx/drive_c", + ) + ) + if path is None: - return Path(screenshot_response["path"]) + return screenshot_response_path else: - source_path = Path(screenshot_response["path"]) + source_path = Path(screenshot_response_path) dest_path = path source_path.rename(dest_path) return dest_path diff --git a/src/lua/api.lua b/src/lua/api.lua index 7f80043..7ff6ef2 100644 --- a/src/lua/api.lua +++ b/src/lua/api.lua @@ -1000,13 +1000,291 @@ API.functions["shop"] = function(args) API.send_response(game_state) end, } - -- TODO: add other shop actions (open_pack) + elseif action == "buy_booster" then + -- Validate index argument + if args.index == nil then + API.send_error_response("Missing required field: index", ERROR_CODES.MISSING_ARGUMENTS, { field = "index" }) + return + end + + local area = G.shop_booster + + if not area then + API.send_error_response("Booster area not found in shop", ERROR_CODES.INVALID_GAME_STATE, {}) + return + end + + -- Get booster index (1-based) and validate range + local card_pos = args.index + 1 + if not area.cards or not area.cards[card_pos] then + API.send_error_response( + "Booster index out of range", + ERROR_CODES.PARAMETER_OUT_OF_RANGE, + { index = args.index, valid_range = "0-" .. tostring(#area.cards - 1) } + ) + return + end + + local card = area.cards[card_pos] + -- Check affordability + local dollars_before = G.GAME.dollars + if dollars_before < card.cost then + API.send_error_response( + "Not enough dollars to buy booster", + ERROR_CODES.INVALID_ACTION, + { dollars = dollars_before, cost = card.cost } + ) + return + end + + -- Activate the booster's use button to open it + local open_button = card.children.buy_button and card.children.buy_button.definition + if not open_button then + API.send_error_response("Booster has no open button", ERROR_CODES.INVALID_GAME_STATE, { index = args.index }) + return + end + + G.FUNCS.use_card(open_button) + + -- Wait until we enter a pack state + ---@type PendingRequest + API.pending_requests["shop"] = { + condition = function() + return utils.COMPLETION_CONDITIONS["shop"]["buy_booster"]() + end, + action = function() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } else - API.send_error_response( - "Invalid action for shop", - ERROR_CODES.INVALID_ACTION, - { action = action, valid_actions = { "next_round", "buy_card", "reroll" } } - ) + API.send_error_response("Invalid action for shop", ERROR_CODES.INVALID_ACTION, { + action = action, + valid_actions = { + "next_round", + "buy_card", + "reroll", + "buy_and_use_card", + "redeem_voucher", + "buy_booster", + }, + }) + return + end +end + +---Opens a booster pack and selects or skips cards +---@param args OpenPackArgs The open pack action arguments +API.functions["open_pack"] = function(args) + -- Validate required parameters + local success, error_message, error_code, context = validate_request(args, { "action" }) + if not success then + ---@cast error_message string + ---@cast error_code string + API.send_error_response(error_message, error_code, context) + return + end + + local action = args.action + + if action == "select_card" then + -- Validate required index parameter + if args.index == nil then + API.send_error_response("Missing required field: index", ERROR_CODES.MISSING_ARGUMENTS, { field = "index" }) + return + end + + -- Validate that pack cards exist + if not G.pack_cards or not G.pack_cards.cards or #G.pack_cards.cards == 0 then + API.send_error_response( + "No pack cards available. Open a pack first using the buy_booster action from the shop.", + ERROR_CODES.MISSING_GAME_OBJECT, + { pack_cards_available = false } + ) + return + end + + -- Convert from 0-based to 1-based indexing + local card_index = args.index + 1 + + -- Validate card index is in range + if card_index < 1 or card_index > #G.pack_cards.cards then + API.send_error_response( + "Card index out of range", + ERROR_CODES.PARAMETER_OUT_OF_RANGE, + { index = args.index, valid_range = "0-" .. tostring(#G.pack_cards.cards - 1) } + ) + return + end + -- Get the selected card from the pack + local selected_card = G.pack_cards.cards[card_index] + + -- Check if the card can be selected (for jokers, check space availability) + if selected_card.ability.set == "Joker" then + -- Check if there's room for the joker (or if it has negative edition which doesn't take space) + local has_negative = selected_card.edition and selected_card.edition.negative + if not has_negative and #G.jokers.cards >= G.jokers.config.card_limit then + API.send_error_response( + "Cannot select joker: joker slots are full", + ERROR_CODES.INVALID_ACTION, + { index = args.index, joker_slots_full = true } + ) + return + end + end + + -- Handle consumables (Tarot/Planet/Spectral) that require card selection + if selected_card.ability.consumeable then + -- Get consumable's card requirements + local max_cards = selected_card.ability.consumeable.max_highlighted + local min_cards = selected_card.ability.consumeable.min_highlighted or 1 + local consumable_name = selected_card.ability.name or "Unknown" + local required_cards = max_cards ~= nil + + -- Validate cards parameter type if provided + if args.cards ~= nil then + if type(args.cards) ~= "table" then + API.send_error_response( + "Invalid parameter type for cards. Expected array, got " .. tostring(type(args.cards)), + ERROR_CODES.INVALID_PARAMETER, + { parameter = "cards", expected_type = "array" } + ) + return + end + + -- Validate all elements are numbers + for i, card_idx in ipairs(args.cards) do + if type(card_idx) ~= "number" then + API.send_error_response( + "Invalid card index type. Expected number, got " .. tostring(type(card_idx)), + ERROR_CODES.INVALID_PARAMETER, + { index = i - 1, value_type = type(card_idx) } + ) + return + end + end + end + + -- The consumable does not require any card selection + if not required_cards and args.cards then + if #args.cards > 0 then + API.send_error_response( + "The selected consumable does not require card selection. Cards array must be empty or no cards array at all.", + ERROR_CODES.INVALID_PARAMETER, + { consumable_name = consumable_name } + ) + return + end + -- If cards=[] (empty), that's fine, just skip the card selection logic + end + + if required_cards then + if G.STATE ~= 999 then + API.send_error_response( + "Cannot use consumable with cards when not in pack state. Pack state is required for card selection.", + ERROR_CODES.INVALID_GAME_STATE, + { current_state = G.STATE, required_state = 999 } + ) + return + end + + local num_cards = args.cards == nil and 0 or #args.cards + if num_cards < min_cards or num_cards > max_cards then + local range_msg = min_cards == max_cards and ("exactly " .. min_cards) or (min_cards .. "-" .. max_cards) + API.send_error_response( + "Invalid number of cards for " + .. consumable_name + .. ". Expected " + .. range_msg + .. ", got " + .. tostring(num_cards), + ERROR_CODES.PARAMETER_OUT_OF_RANGE, + { cards_count = num_cards, min_cards = min_cards, max_cards = max_cards, consumable_name = consumable_name } + ) + return + end + + -- Convert from 0-based to 1-based indexing + local hand_cards = {} + for i, card_idx in ipairs(args.cards) do + hand_cards[i] = card_idx + 1 + end + + -- Check that all cards exist and are selectable + for _, hand_card_index in ipairs(hand_cards) do + if not G.hand or not G.hand.cards or not G.hand.cards[hand_card_index] then + API.send_error_response( + "Invalid card index", + ERROR_CODES.INVALID_CARD_INDEX, + { card_index = hand_card_index - 1, hand_size = G.hand and G.hand.cards and #G.hand.cards or 0 } + ) + return + end + end + + -- Clear any existing highlights before selecting new cards + if G.hand then + G.hand:unhighlight_all() + end + + -- Select cards for the consumable to target + for _, hand_card_index in ipairs(hand_cards) do + G.hand.cards[hand_card_index]:click() + end + end + end + + -- Use the card directly by calling G.FUNCS.use_card with a mock UI element + -- This is the same pattern used throughout the Balatro codebase for programmatic card usage + local mock_element = { + config = { + ref_table = selected_card, + }, + } + + G.FUNCS.use_card(mock_element) + + ---@type PendingRequest + API.pending_requests["open_pack"] = { + condition = function() + return utils.COMPLETION_CONDITIONS["open_pack"]["select_cards"]() + end, + action = function() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } + elseif action == "skip" then + -- Validate that pack cards exist + if not G.pack_cards or not G.pack_cards.cards then + API.send_error_response( + "No pack available to skip", + ERROR_CODES.MISSING_GAME_OBJECT, + { pack_cards_available = false } + ) + return + end + + -- Call the skip_booster function directly + -- The skip button doesn't have a simple ID we can look up, + -- so we use the skip_booster function which is what the button calls anyway + G.FUNCS.skip_booster({}) + + ---@type PendingRequest + API.pending_requests["open_pack"] = { + condition = function() + return utils.COMPLETION_CONDITIONS["open_pack"]["skip"]() + end, + action = function() + local game_state = utils.get_game_state() + API.send_response(game_state) + end, + } + else + API.send_error_response("Invalid action for open_pack", ERROR_CODES.INVALID_ACTION, { + action = action, + valid_actions = { "select_card", "skip" }, + }) return end end @@ -1094,6 +1372,7 @@ end ---Uses a consumable at the specified index ---Call G.FUNCS.use_card() to use the consumable at the given index +---Some consumables require card selection from the hand. Provide optional "cards" parameter for these. ---@param args UseConsumableArgs The use consumable action arguments API.functions["use_consumable"] = function(args) -- Validate required parameters diff --git a/src/lua/types.lua b/src/lua/types.lua index 31ab64d..39e426d 100644 --- a/src/lua/types.lua +++ b/src/lua/types.lua @@ -66,10 +66,13 @@ ---@field consumables number[] Array of consumable indices for every consumable (0-based) ---@class ShopActionArgs ----@field action "next_round" | "buy_card" | "reroll" | "redeem_voucher" | "buy_and_use_card" The action to perform ----@field index? number The index of the card to act on (buy, buy_and_use, redeem, open) (0-based) +---@field action "next_round" | "buy_card" | "reroll" | "redeem_voucher" | "buy_and_use_card" | "buy_booster" The action to perform +---@field index? number The index of the card to act on (buy, buy_and_use, redeem, buy_booster) (0-based) --- TODO: add the other action "open_pack" +---@class OpenPackArgs +---@field action "select_card" | "skip" The action to perform on the pack +---@field index? number The index of the card to select from the pack (0-based, required for select_card) +---@field cards? number[] Array of hand card indices to use the consumable on (0-based, optional, only for consumables) ---@class SellJokerArgs ---@field index number The index of the joker to sell (0-based) diff --git a/src/lua/utils.lua b/src/lua/utils.lua index 71d07e9..639921d 100644 --- a/src/lua/utils.lua +++ b/src/lua/utils.lua @@ -547,6 +547,41 @@ function utils.get_game_state() } end + local pack_cards = nil + if G.pack_cards then + local cards = {} + if G.pack_cards.cards then + for i, card in pairs(G.pack_cards.cards) do + cards[i] = { + ability = { + set = card.ability.set, + effect = card.ability.effect, + name = card.ability.name, + }, + label = card.label, + cost = card.cost, + sell_cost = card.sell_cost, + sort_id = card.sort_id, + config = { + center_key = card.config.center_key, + }, + debuff = card.debuff, + facing = card.facing, + highlighted = card.highlighted, + seal = card.seal, + edition = card.edition, + } + end + end + pack_cards = { + cards = cards, + config = G.pack_cards.config and { + card_count = G.pack_cards.config.card_count, + card_limit = G.pack_cards.config.card_limit, + } or nil, + } + end + return { state = G.STATE, game = game, @@ -556,6 +591,7 @@ function utils.get_game_state() shop_vouchers = shop_vouchers, shop_booster = shop_booster, consumables = consumables, + pack_cards = pack_cards, -- Cards available in an opened booster pack blinds = utils.get_blinds_info(), } end @@ -992,6 +1028,48 @@ utils.COMPLETION_CONDITIONS = { local elapsed = socket.gettime() - condition_timestamps.shop_redeem_voucher return elapsed > 0.10 end, + buy_booster = function() + -- Check if we've left the shop state (pack purchase initiated) + -- Then wait for pack_cards to be ready + -- State 999 is used for all pack types by the mod injector + + -- First, check if we've left the shop + local left_shop = G.STATE ~= G.STATES.SHOP + + if not left_shop then + -- Still in shop, keep waiting + condition_timestamps.shop_buy_booster = nil + return false + end + + -- We've left the shop, now check if pack_cards exists and is ready + local pack_cards_ready = G.pack_cards and G.pack_cards.cards and #G.pack_cards.cards > 0 + + -- State 999 is the universal pack state + local in_pack_state = G.STATE == 999 + local base_condition = in_pack_state + and pack_cards_ready + and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD + and G.STATE_COMPLETE + + if not base_condition then + -- Not ready yet, keep waiting + condition_timestamps.shop_buy_booster = nil + return false + end + + -- Base condition is met, start timing + if not condition_timestamps.shop_buy_booster then + condition_timestamps.shop_buy_booster = socket.gettime() + end + + -- Wait for pack cards to be fully emplaced (cards are added in delayed events) + -- The game uses 1.3*sqrt(GAMESPEED) delays twice before emplacing cards + -- At default speed (GAMESPEED=1), this is ~2.6s total + -- We need to wait longer to ensure cards are fully loaded + local elapsed = socket.gettime() - condition_timestamps.shop_buy_booster + return elapsed > 1.50 + end, }, sell_joker = { [""] = function() @@ -1081,6 +1159,50 @@ utils.COMPLETION_CONDITIONS = { return elapsed > 0.50 end, }, + open_pack = { + ["select_cards"] = function() + -- Check if we've left the pack state and returned to a normal game state + -- State 999 is the universal pack state + + local base_condition = G.STATE ~= 999 and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE + + if not base_condition then + -- Reset timestamp if base condition is not met + condition_timestamps.open_pack_select = nil + return false + end + + -- Base condition is met, start timing + if not condition_timestamps.open_pack_select then + condition_timestamps.open_pack_select = socket.gettime() + end + + -- Check if 0.2 seconds have passed + local elapsed = socket.gettime() - condition_timestamps.open_pack_select + return elapsed > 0.20 + end, + ["skip"] = function() + -- Check if we've left the pack state + -- State 999 is the universal pack state + + local base_condition = G.STATE ~= 999 and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE + + if not base_condition then + -- Reset timestamp if base condition is not met + condition_timestamps.open_pack_skip = nil + return false + end + + -- Base condition is met, start timing + if not condition_timestamps.open_pack_skip then + condition_timestamps.open_pack_skip = socket.gettime() + end + + -- Check if 0.1 seconds have passed + local elapsed = socket.gettime() - condition_timestamps.open_pack_skip + return elapsed > 0.10 + end, + }, } return utils