diff --git a/balatrobot.json b/balatrobot.json index 79460335..b94d3f80 100644 --- a/balatrobot.json +++ b/balatrobot.json @@ -16,7 +16,5 @@ "badge_text_colour": "FFFFFF", "display_name": "BB", "version": "1.4.1", - "dependencies": [ - "Steamodded (>=1.*)" - ] + "dependencies": [] } diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index 8abfdae3..93998b2b 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -79,6 +79,7 @@ BB_SERVER = { current_request_id = nil, client_state = nil, openrpc_spec = nil, + pending_response = false, -- true while waiting for Event to send response } --- Create fresh client state for HTTP parsing @@ -150,6 +151,20 @@ function BB_SERVER.accept() end if client then + -- If we're waiting for an Event to send a response, reject the new + -- connection to protect the pending socket. + if BB_SERVER.pending_response and BB_SERVER.client_socket then + -- Send a quick "busy" response before closing so the client + -- gets a clean HTTP error instead of a TCP abort. + local busy_body = '{"jsonrpc":"2.0","error":{"code":-32002,"message":"Server busy (pending response)","data":{"name":"INVALID_STATE"}},"id":null}' + local busy_resp = format_http_response(503, "Service Unavailable", busy_body) + client:settimeout(1) + client:send(busy_resp) + client:close() + sendDebugMessage("Client rejected with 503 (pending response)", "BB.SERVER") + return false + end + -- Close existing client if any if BB_SERVER.client_socket then BB_SERVER.client_socket:close() @@ -260,6 +275,7 @@ end ---@param status_code number HTTP status code ---@param message string Error message local function send_http_error(status_code, message) + BB_SERVER.pending_response = false local status_texts = { [400] = "Bad Request", [404] = "Not Found", @@ -339,6 +355,7 @@ local function handle_jsonrpc(body, dispatcher) end BB_SERVER.current_request_id = parsed.id + BB_SERVER.pending_response = true -- Dispatch to endpoint if dispatcher and dispatcher.dispatch then @@ -383,6 +400,7 @@ end ---@param response Response.Endpoint ---@return boolean success function BB_SERVER.send_response(response) + BB_SERVER.pending_response = false if not BB_SERVER.client_socket then return false end diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 77316976..8f1ececc 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -70,13 +70,31 @@ return { end end - -- NOTE: Clear any existing highlights before selecting new cards - -- prevent state pollution. This is a bit of a hack but could interfere - -- with Boss Blind like Cerulean Bell. - G.hand:unhighlight_all() + -- Intelligently match selection to args.cards + -- We DON'T use unhighlight_all() because it cheats Boss Blinds (Cerulean Bell). + -- Instead, we toggle cards that should be selected but aren't, + -- and we don't touch cards that are already selected (even if forced). + + local target_indices = {} + for _, idx in ipairs(args.cards) do + target_indices[idx + 1] = true + end - for _, card_index in ipairs(args.cards) do - G.hand.cards[card_index + 1]:click() + for i, card in ipairs(G.hand.cards) do + local is_selected = false + for _, highlighted_card in ipairs(G.hand.highlighted) do + if highlighted_card == card then + is_selected = true + break + end + end + + local should_be_selected = target_indices[i] + if should_be_selected and not is_selected then + card:click() + end + -- Note: We don't UNSELECT cards that the AI didn't ask for, + -- because if they are selected, they might be forced by a Boss. end -- Log the cards being discarded @@ -92,7 +110,7 @@ return { assert(discard_button ~= nil, "discard() discard button not found") G.FUNCS.discard_cards_from_highlighted(discard_button) - local draw_to_hand = false + local left_selecting = false G.E_MANAGER:add_event(Event({ trigger = "immediate", @@ -101,12 +119,14 @@ return { created_on_pause = true, func = function() -- State progression for discard: - -- Discard always continues current round: HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND - if G.STATE == G.STATES.DRAW_TO_HAND then - draw_to_hand = true + -- SELECTING_HAND -> HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND + -- Track that we left SELECTING_HAND (animation started) to avoid + -- returning before the discard animation even begins. + if G.STATE ~= G.STATES.SELECTING_HAND then + left_selecting = true end - if draw_to_hand and G.buttons and G.STATE == G.STATES.SELECTING_HAND then + if left_selecting and G.buttons and G.STATE == G.STATES.SELECTING_HAND then sendDebugMessage("Return discard()", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index d60efa80..95d718db 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -260,11 +260,12 @@ return { return true end else - -- Pack closes - wait for return to shop + -- Pack closes - wait for return to a stable state local pack_closed = not G.pack_cards or G.pack_cards.REMOVED - local back_to_shop = G.STATE == G.STATES.SHOP + local stable_state = G.STATE == G.STATES.SHOP + or G.STATE == G.STATES.BLIND_SELECT - if pack_closed and back_to_shop then + if pack_closed and stable_state then sendDebugMessage("Return pack() after selection", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true @@ -283,15 +284,17 @@ return { sendDebugMessage(string.format("Pack: skipping (%d cards remaining)", pack_count), "BB.ENDPOINTS") G.FUNCS.skip_booster({}) - -- Wait for pack to close and return to shop + -- Wait for pack to close and return to a stable state + -- (shop if opened from shop, blind_select if opened from tag reward) G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, func = function() local pack_closed = not G.pack_cards or G.pack_cards.REMOVED - local back_to_shop = G.STATE == G.STATES.SHOP + local stable_state = G.STATE == G.STATES.SHOP + or G.STATE == G.STATES.BLIND_SELECT - if pack_closed and back_to_shop then + if pack_closed and stable_state then sendDebugMessage("Return pack() after skip", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 1b9f0a98..00a68b14 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -62,13 +62,31 @@ return { end end - -- NOTE: Clear any existing highlights before selecting new cards - -- prevent state pollution. This is a bit of a hack but could interfere - -- with Boss Blind like Cerulean Bell. - G.hand:unhighlight_all() + -- Intelligently match selection to args.cards + -- We DON'T use unhighlight_all() because it cheats Boss Blinds (Cerulean Bell). + -- Instead, we toggle cards that should be selected but aren't, + -- and we don't touch cards that are already selected (even if forced). + + local target_indices = {} + for _, idx in ipairs(args.cards) do + target_indices[idx + 1] = true + end - for _, card_index in ipairs(args.cards) do - G.hand.cards[card_index + 1]:click() + for i, card in ipairs(G.hand.cards) do + local is_selected = false + for _, highlighted_card in ipairs(G.hand.highlighted) do + if highlighted_card == card then + is_selected = true + break + end + end + + local should_be_selected = target_indices[i] + if should_be_selected and not is_selected then + card:click() + end + -- Note: We don't UNSELECT cards that the AI didn't ask for, + -- because if they are selected, they might be forced by a Boss. end -- Log the cards being played @@ -80,8 +98,7 @@ return { assert(play_button ~= nil, "play() play button not found") G.FUNCS.play_cards_from_highlighted(play_button) - local hand_played = false - local draw_to_hand = false + local left_selecting = false -- NOTE: GAME_OVER detection cannot happen inside this event function -- because when G.STATE becomes GAME_OVER, the game sets G.SETTINGS.paused = true, @@ -101,20 +118,13 @@ return { -- Win game: HAND_PLAYED -> NEW_ROUND -> ROUND_EVAL (with G.GAME.won = true) -- Keep playing current round: HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND - -- Track state transitions - if G.STATE == G.STATES.HAND_PLAYED then - hand_played = true - end - - if G.STATE == G.STATES.DRAW_TO_HAND then - draw_to_hand = true + -- Track that we left SELECTING_HAND (animation started). + -- Using != SELECTING_HAND instead of tracking intermediate states + -- avoids race conditions when states transition between frames. + if G.STATE ~= G.STATES.SELECTING_HAND then + left_selecting = true end - -- if G.STATE == G.STATES.GAME_OVER then - -- -- NOTE: GAME_OVER is detected by gamestate.on_game_over callback in love.update - -- return true - -- end - if G.STATE == G.STATES.ROUND_EVAL then -- Early exit if basic conditions not met if not G.round_eval or not G.STATE_COMPLETE or G.CONTROLLER.locked then @@ -151,7 +161,7 @@ return { end end - if draw_to_hand and hand_played and G.buttons and G.STATE == G.STATES.SELECTING_HAND then + if left_selecting and G.buttons and G.STATE == G.STATES.SELECTING_HAND then sendDebugMessage("Return play() - same round", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index a7cc2b97..b1ad8344 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -237,8 +237,20 @@ local function extract_card_modifier(card) modifier.edition = string.upper(card.edition.type) end - -- Enhancement (from ability.name for enhanced cards) - if card.ability and card.ability.effect and card.ability.effect ~= "Base" then + -- Enhancement (Check center key for robust identification) + if card.config and card.config.center and card.config.center.set == 'Enhanced' then + local key = card.config.center.key + if key == 'm_bonus' then modifier.enhancement = 'BONUS' + elseif key == 'm_mult' then modifier.enhancement = 'MULT' + elseif key == 'm_wild' then modifier.enhancement = 'WILD' + elseif key == 'm_glass' then modifier.enhancement = 'GLASS' + elseif key == 'm_steel' then modifier.enhancement = 'STEEL' + elseif key == 'm_stone' then modifier.enhancement = 'STONE' + elseif key == 'm_lucky' then modifier.enhancement = 'LUCKY' + elseif key == 'm_gold' then modifier.enhancement = 'GOLD' + end + elseif card.ability and card.ability.effect and card.ability.effect ~= "Base" then + -- Fallback for modded enhancements modifier.enhancement = string.upper(card.ability.effect:gsub(" Card", "")) end @@ -357,7 +369,7 @@ local function extract_card(card) end end - return { + local result = { id = card.sort_id or 0, key = key, set = set, @@ -367,6 +379,43 @@ local function extract_card(card) state = extract_card_state(card), cost = extract_card_cost(card), } + + -- Export played_this_ante for playing cards (The Pillar tracking) + if card.ability and card.ability.played_this_ante then + result.played_this_ante = true + end + + -- Export ability dict for jokers (runtime state for growth jokers) + if set == "JOKER" and card.ability then + local ability_data = {} + for k, v in pairs(card.ability) do + -- Only export simple types (number, string, boolean) and simple tables + if type(v) == "number" or type(v) == "string" or type(v) == "boolean" then + ability_data[k] = v + elseif type(v) == "table" then + -- Serialize one level of nested tables (e.g., extra = {chips = 45}) + local nested = {} + local has_values = false + for nk, nv in pairs(v) do + if type(nv) == "number" or type(nv) == "string" or type(nv) == "boolean" then + nested[nk] = nv + has_values = true + end + end + if has_values then + ability_data[k] = nested + end + end + end + -- Only add if there are meaningful fields + local has_ability = false + for _ in pairs(ability_data) do has_ability = true; break end + if has_ability then + result.ability = ability_data + end + end + + return result end -- ========================================================================== @@ -647,12 +696,31 @@ function gamestate.get_blinds_info() blinds.small.name = small_blind.name or "Small Blind" blinds.small.score = math.floor(base_amount * (small_blind.mult or 1) * ante_scaling) blinds.small.effect = get_blind_effect_from_ui(small_blind) + blinds.small.debuff = small_blind.debuff or {} + blinds.small.mult = small_blind.mult or 1 -- Set status if blind_states.Small then blinds.small.status = convert_status_to_enum(blind_states.Small) end + -- Export runtime state if this blind is current + if blind_states.Small == "Current" and G.GAME.blind then + local b = G.GAME.blind + if b.hands and type(b.hands) == "table" then + local used = {} + for name, val in pairs(b.hands) do + if val then used[name] = true end + end + blinds.small.eye_hands = used + end + if b.only_hand and type(b.only_hand) == "string" then + blinds.small.mouth_hand = b.only_hand + end + if b.hands_sub then blinds.small.hands_sub = b.hands_sub end + if b.discards_sub then blinds.small.discards_sub = b.discards_sub end + end + -- Get tag information local small_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Small if small_tag_key then @@ -671,12 +739,31 @@ function gamestate.get_blinds_info() blinds.big.name = big_blind.name or "Big Blind" blinds.big.score = math.floor(base_amount * (big_blind.mult or 1.5) * ante_scaling) blinds.big.effect = get_blind_effect_from_ui(big_blind) + blinds.big.debuff = big_blind.debuff or {} + blinds.big.mult = big_blind.mult or 1.5 -- Set status if blind_states.Big then blinds.big.status = convert_status_to_enum(blind_states.Big) end + -- Export runtime state if this blind is current + if blind_states.Big == "Current" and G.GAME.blind then + local b = G.GAME.blind + if b.hands and type(b.hands) == "table" then + local used = {} + for name, val in pairs(b.hands) do + if val then used[name] = true end + end + blinds.big.eye_hands = used + end + if b.only_hand and type(b.only_hand) == "string" then + blinds.big.mouth_hand = b.only_hand + end + if b.hands_sub then blinds.big.hands_sub = b.hands_sub end + if b.discards_sub then blinds.big.discards_sub = b.discards_sub end + end + -- Get tag information local big_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Big if big_tag_key then @@ -695,11 +782,30 @@ function gamestate.get_blinds_info() blinds.boss.name = boss_blind.name or "Boss Blind" blinds.boss.score = math.floor(base_amount * (boss_blind.mult or 2) * ante_scaling) blinds.boss.effect = get_blind_effect_from_ui(boss_blind) + blinds.boss.debuff = boss_blind.debuff or {} + blinds.boss.mult = boss_blind.mult or 2 -- Set status if blind_states.Boss then blinds.boss.status = convert_status_to_enum(blind_states.Boss) end + + -- Export runtime state if this blind is current + if blind_states.Boss == "Current" and G.GAME.blind then + local b = G.GAME.blind + if b.hands and type(b.hands) == "table" then + local used = {} + for name, val in pairs(b.hands) do + if val then used[name] = true end + end + blinds.boss.eye_hands = used + end + if b.only_hand and type(b.only_hand) == "string" then + blinds.boss.mouth_hand = b.only_hand + end + if b.hands_sub then blinds.boss.hands_sub = b.hands_sub end + if b.discards_sub then blinds.boss.discards_sub = b.discards_sub end + end else -- Fallback if boss blind not yet determined blinds.boss.name = "Boss Blind" diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 53f43b13..a1c6a2f4 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -55,6 +55,12 @@ ---@field score integer Score requirement to beat this blind ---@field tag_name string? Name of the tag associated with this blind (Small/Big only) ---@field tag_effect string? Description of the tag's effect (Small/Big only) +---@field debuff table? Debuff constraints from G.P_BLINDS (e.g., {suit="Clubs"}, {is_face="face"}) +---@field mult number? Score multiplier (default 1, e.g., 4 for The Wall, 6 for Violet Vessel) +---@field eye_hands table? The Eye: which hand types have been played (only for current blind) +---@field mouth_hand string? The Mouth: locked hand type (only for current blind) +---@field hands_sub integer? The Needle: number of hands subtracted (only for current blind) +---@field discards_sub integer? The Water: number of discards subtracted (only for current blind) ---@class Area ---@field count integer Current number of cards in this area @@ -71,6 +77,7 @@ ---@field modifier Card.Modifier Modifier information (seals, editions, enhancements) ---@field state Card.State Current state information (debuff, hidden, highlighted) ---@field cost Card.Cost Cost information (buy/sell prices) +---@field played_this_ante boolean? Whether this card was played this ante (for The Pillar) ---@class Card.Value ---@field suit Card.Value.Suit? Suit (Hearts, Diamonds, Clubs, Spades) - only for playing cards