Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions balatrobot.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,5 @@
"badge_text_colour": "FFFFFF",
"display_name": "BB",
"version": "1.4.1",
"dependencies": [
"Steamodded (>=1.*)"
]
"dependencies": []
}
18 changes: 18 additions & 0 deletions src/lua/core/server.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 31 additions & 11 deletions src/lua/endpoints/discard.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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)
Expand Down
15 changes: 9 additions & 6 deletions src/lua/endpoints/pack.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
52 changes: 31 additions & 21 deletions src/lua/endpoints/play.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading