From 944170be40c78b5473a39bbe1b469f28b75f3c87 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 24 Feb 2026 11:10:29 +0100 Subject: [PATCH 001/121] fix(gamestate): use localize for voucher effect descriptions Previously, used_vouchers extracted descriptions from static voucher_data.description which was unreliable. Now uses get_voucher_effect() that fetches effect text via the game's localize() function with proper loc_vars for each voucher type. Also adds strip_color_codes() helper and comprehensive parametrized tests covering all 32 voucher types. Closes #154. --- src/lua/utils/gamestate.lua | 90 ++++++++++++++++++++-- tests/lua/endpoints/test_gamestate.py | 103 ++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 6 deletions(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index a7cc2b97..6569eddb 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -515,6 +515,88 @@ local function get_blind_effect_from_ui(blind_config) return table.concat(effect_parts, " ") end +---Strips Balatro color codes from text +---Color codes are in format {C:color}text{} or {X:color}text{} +---@param text string The text with color codes +---@return string clean_text The text without color codes +local function strip_color_codes(text) + if not text then + return "" + end + -- Remove color codes: {C:color_name}, {X:mult}, etc. and closing {} + return text:gsub("%b{}", ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") +end + +---Gets voucher effect description using the game's localize function +---Uses the same approach as generate_card_ui() in common_events.lua +---@param voucher_key string The voucher key (e.g., "v_overstock_norm") +---@return string effect The effect description +local function get_voucher_effect(voucher_key) + if not voucher_key then + return "" + end + + -- Get voucher config from G.P_CENTERS + local center = G.P_CENTERS and G.P_CENTERS[voucher_key] + if not center then + return "" + end + + -- Build loc_vars based on voucher name (mirrors common_events.lua:2559-2576) + local loc_vars = {} + local name = center.name + + if name == "Overstock" or name == "Overstock Plus" then + -- No vars needed + elseif name == "Tarot Merchant" or name == "Tarot Tycoon" then + loc_vars = { center.config.extra_disp } + elseif name == "Planet Merchant" or name == "Planet Tycoon" then + loc_vars = { center.config.extra_disp } + elseif name == "Hone" or name == "Glow Up" then + loc_vars = { center.config.extra } + elseif name == "Reroll Surplus" or name == "Reroll Glut" then + loc_vars = { center.config.extra } + elseif name == "Grabber" or name == "Nacho Tong" then + loc_vars = { center.config.extra } + elseif name == "Wasteful" or name == "Recyclomancy" then + loc_vars = { center.config.extra } + elseif name == "Seed Money" or name == "Money Tree" then + loc_vars = { center.config.extra / 5 } + elseif name == "Blank" or name == "Antimatter" then + -- No vars needed + elseif name == "Hieroglyph" or name == "Petroglyph" then + loc_vars = { center.config.extra } + elseif name == "Director's Cut" or name == "Retcon" then + loc_vars = { center.config.extra } + elseif name == "Paint Brush" or name == "Palette" then + loc_vars = { center.config.extra } + elseif name == "Telescope" or name == "Observatory" then + loc_vars = { center.config.extra } + elseif name == "Clearance Sale" or name == "Liquidation" then + loc_vars = { center.config.extra } + end + + -- Use localize to get description text + if not localize then ---@diagnostic disable-line: undefined-global + return "" + end + + local text_lines = localize({ ---@diagnostic disable-line: undefined-global + type = "raw_descriptions", + key = voucher_key, + set = "Voucher", + vars = loc_vars, + }) + + if not text_lines or type(text_lines) ~= "table" then + return "" + end + + -- Concatenate and strip color codes + local text = table.concat(text_lines, " ") + return strip_color_codes(text) +end + ---Gets tag information using localize function (same approach as Tag:set_text) ---@param tag_key string The tag key from G.P_TAGS ---@return table tag_info {name: string, effect: string} @@ -757,12 +839,8 @@ function gamestate.get_gamestate() -- Used vouchers (table) if G.GAME.used_vouchers then local used_vouchers = {} - for voucher_name, voucher_data in pairs(G.GAME.used_vouchers) do - if type(voucher_data) == "table" and voucher_data.description then - used_vouchers[voucher_name] = voucher_data.description - else - used_vouchers[voucher_name] = "" - end + for voucher_name, _ in pairs(G.GAME.used_vouchers) do + used_vouchers[voucher_name] = get_voucher_effect(voucher_name) end state_data.used_vouchers = used_vouchers end diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index e35af2ba..4ada0313 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -3,6 +3,7 @@ import re import httpx +import pytest from tests.lua.conftest import api, assert_gamestate_response, load_fixture @@ -816,6 +817,108 @@ def test_cost_sell_owned_joker(self, client: httpx.Client) -> None: assert joker["cost"]["sell"] > 0 +class TestGamestateUsedVouchers: + """Test gamestate used_vouchers effect text extraction.""" + + @pytest.mark.parametrize( + "voucher_key,expected_effect", + [ + # --- No loc_vars --- + ("v_overstock_norm", "+1 card slot available in shop"), + ("v_overstock_plus", "+1 card slot available in shop"), + ("v_crystal_ball", "+1 consumable slot"), + ( + "v_omen_globe", + "Spectral cards may appear in any of the Arcana Packs", + ), + ( + "v_telescope", + "Celestial Packs always contain the Planet card for your " + "most played poker hand", + ), + ("v_magic_trick", "Playing cards can be purchased from the shop"), + ( + "v_illusion", + "Playing cards in shop may have an Enhancement, Edition, and/or a Seal", + ), + ("v_blank", "Does nothing?"), + ("v_antimatter", "+1 Joker Slot"), + # --- Uses center.config.extra_disp --- + ( + "v_tarot_merchant", + "Tarot cards appear 2X more frequently in the shop", + ), + ( + "v_tarot_tycoon", + "Tarot cards appear 4X more frequently in the shop", + ), + ( + "v_planet_merchant", + "Planet cards appear 2X more frequently in the shop", + ), + ( + "v_planet_tycoon", + "Planet cards appear 4X more frequently in the shop", + ), + # --- Uses center.config.extra --- + ( + "v_hone", + "Foil, Holographic, and Polychrome cards appear 2X more often", + ), + ( + "v_glow_up", + "Foil, Holographic, and Polychrome cards appear 4X more often", + ), + ("v_reroll_surplus", "Rerolls cost $2 less"), + ("v_reroll_glut", "Rerolls cost $2 less"), + ("v_grabber", "Permanently gain +1 hand per round"), + ("v_nacho_tong", "Permanently gain +1 hand per round"), + ("v_wasteful", "Permanently gain +1 discard each round"), + ("v_recyclomancy", "Permanently gain +1 discard each round"), + ("v_clearance_sale", "All cards and packs in shop are 25% off"), + ("v_liquidation", "All cards and packs in shop are 50% off"), + ( + "v_directors_cut", + "Reroll Boss Blind 1 time per Ante, $10 per roll", + ), + ("v_retcon", "Reroll Boss Blind unlimited times, $10 per roll"), + ("v_paint_brush", "+1 hand size"), + ("v_palette", "+1 hand size"), + ("v_hieroglyph", "-1 Ante, -1 hand each round"), + ("v_petroglyph", "-1 Ante, -1 discard each round"), + # --- Uses center.config.extra / 5 --- + ( + "v_seed_money", + "Raise the cap on interest earned in each round to $10", + ), + ( + "v_money_tree", + "Raise the cap on interest earned in each round to $20", + ), + # --- Uses center.config.extra (mult) --- + ( + "v_observatory", + "Planet cards in your consumable area give X1.5 Mult " + "for their specified poker hand", + ), + ], + ids=lambda v: v if v.startswith("v_") else "", + ) + def test_voucher_effect_text( + self, client: httpx.Client, voucher_key: str, expected_effect: str + ) -> None: + """Test that used_vouchers contains correct effect text for each voucher.""" + load_fixture( + client, + "gamestate", + "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE", + ) + response = api(client, "set", {"used_voucher": voucher_key}) + gamestate = assert_gamestate_response(response) + assert voucher_key in gamestate["used_vouchers"] + assert gamestate["used_vouchers"][voucher_key] == expected_effect + + class TestGamestateCardModifiers: """Test gamestate card modifiers.""" From 20f53ccac0b7a1f4eebc836b2450abd2aefd4f64 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 24 Feb 2026 12:08:49 +0100 Subject: [PATCH 002/121] feat(api): add suggested actions to error messages Improve error messages across 6 endpoint files by adding actionable guidance to help bots self-heal from failed tool calls. Changes: - buy.lua: Add endpoint suggestions for empty shop/slot errors - use.lua: Add card parameter guidance for consumable errors - discard.lua/play.lua: Add card limit suggestions - pack.lua: Add pack buying and target selection hints - skip.lua: Add boss blind selection suggestion - Update test_buy.py to match new error messages Closes #148. --- src/lua/endpoints/buy.lua | 12 +++++++----- src/lua/endpoints/discard.lua | 4 ++-- src/lua/endpoints/pack.lua | 15 ++++++++++----- src/lua/endpoints/play.lua | 2 +- src/lua/endpoints/skip.lua | 2 +- src/lua/endpoints/use.lua | 16 ++++++++++------ tests/lua/endpoints/test_buy.py | 10 +++++----- 7 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 77e42963..0e615138 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -86,11 +86,11 @@ return { if #area.cards == 0 then local msg if args.card then - msg = "No jokers/consumables/cards in the shop. Reroll to restock the shop" + msg = "No jokers/consumables/cards in the shop. Use `reroll` to restock the shop." elseif args.voucher then - msg = "No vouchers to redeem. Defeat boss blind to restock" + msg = "No vouchers to redeem. Defeat boss blind to restock." elseif args.pack then - msg = "No packs to open" + msg = "No packs to open. Use `next_round` to advance to the next blind and restock the shop." end send_response({ message = msg, @@ -136,7 +136,8 @@ return { message = "Cannot purchase joker card, joker slots are full. Current: " .. gamestate.jokers.count .. ", Limit: " - .. gamestate.jokers.limit, + .. gamestate.jokers.limit + .. ". Sell a joker using `sell` to free a slot.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -150,7 +151,8 @@ return { message = "Cannot purchase consumable card, consumable slots are full. Current: " .. gamestate.consumables.count .. ", Limit: " - .. gamestate.consumables.limit, + .. gamestate.consumables.limit + .. ". Use `use` to activate a consumable or `sell` to remove one.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 77316976..cd40854a 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -46,7 +46,7 @@ return { if G.GAME.current_round.discards_left <= 0 then send_response({ - message = "No discards left", + message = "No discards left. Play cards using `play` instead.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -54,7 +54,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ - message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", + message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index d60efa80..2e9a338f 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -113,7 +113,7 @@ return { -- Validate pack_cards exists if not G.pack_cards or G.pack_cards.REMOVED then send_response({ - message = "No pack is currently open", + message = "No pack is currently open. Use `buy` with `pack` parameter to buy and open a pack.", name = BB_ERROR_NAMES.INVALID_STATE, }) return @@ -144,7 +144,8 @@ return { message = "Cannot select joker, joker slots are full. Current: " .. joker_count .. ", Limit: " - .. joker_limit, + .. joker_limit + .. ". Sell a joker using `sell` to free a slot.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -160,7 +161,11 @@ return { local joker_count = G.jokers and G.jokers.config and G.jokers.config.card_count or 0 if joker_count == 0 then send_response({ - message = string.format("Card '%s' requires at least 1 joker. Current: %d", card_key, joker_count), + message = string.format( + "Card '%s' requires at least 1 joker. Current: %d. Ensure you have enough jokers before selecting this card.", + card_key, + joker_count + ), name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -173,14 +178,14 @@ return { local msg if req.min == req.max then msg = string.format( - "Card '%s' requires exactly %d target card(s). Provided: %d", + "Card '%s' requires exactly %d target card(s). Provided: %d. Ensure you have the required targets before selecting.", card_key, req.min, target_count ) else msg = string.format( - "Card '%s' requires %d-%d target card(s). Provided: %d", + "Card '%s' requires %d-%d target card(s). Provided: %d. Ensure you have the required targets before selecting.", card_key, req.min, req.max, diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 1b9f0a98..b450039a 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -46,7 +46,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ - message = "You can only play " .. G.hand.config.highlighted_limit .. " cards", + message = "You can only play " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 0e684bed..5fea1c77 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -36,7 +36,7 @@ return { if blind.type == "BOSS" then sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS") send_response({ - message = "Cannot skip Boss blind", + message = "Cannot skip Boss blind. Use `select` to select and play the boss blind.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index 7801ed37..dedba80c 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -62,7 +62,7 @@ return { send_response({ message = "Consumable '" .. consumable_card.ability.name - .. "' requires card selection and can only be used in SELECTING_HAND state", + .. "' requires card selection and can only be used in SELECTING_HAND state.", name = BB_ERROR_NAMES.INVALID_STATE, }) return @@ -72,7 +72,9 @@ return { if requires_cards then if not args.cards or #args.cards == 0 then send_response({ - message = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", + message = "Consumable '" + .. consumable_card.ability.name + .. "' requires card selection. Provide target cards via the `cards` parameter.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -100,7 +102,7 @@ return { if min_cards == max_cards and card_count ~= min_cards then send_response({ message = string.format( - "Consumable '%s' requires exactly %d card%s (provided: %d)", + "Consumable '%s' requires exactly %d card%s (provided: %d). Provide the correct number of cards via the `cards` parameter.", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -115,7 +117,7 @@ return { if card_count < min_cards then send_response({ message = string.format( - "Consumable '%s' requires at least %d card%s (provided: %d)", + "Consumable '%s' requires at least %d card%s (provided: %d). Provide more cards via the `cards` parameter.", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -129,7 +131,7 @@ return { if card_count > max_cards then send_response({ message = string.format( - "Consumable '%s' requires at most %d card%s (provided: %d)", + "Consumable '%s' requires at most %d card%s (provided: %d). Provide fewer cards via the `cards` parameter.", consumable_card.ability.name, max_cards, max_cards == 1 and "" or "s", @@ -176,7 +178,9 @@ return { -- Step 8: Space Check (not tested) if consumable_card:check_use() then send_response({ - message = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", + message = "Cannot use consumable '" + .. consumable_card.ability.name + .. "': insufficient space. Use `sell` or `use` to free up space.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 5aaa6081..82e08189 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -46,7 +46,7 @@ def test_buy_no_card_in_shop_area(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 0}), "BAD_REQUEST", - "No jokers/consumables/cards in the shop. Reroll to restock the shop", + "No jokers/consumables/cards in the shop. Use `reroll` to restock the shop.", ) def test_buy_invalid_card_index(self, client: httpx.Client) -> None: @@ -110,7 +110,7 @@ def test_buy_joker_slots_full(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 0}), "BAD_REQUEST", - "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", + "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5. Sell a joker using `sell` to free a slot.", ) def test_buy_consumable_slots_full(self, client: httpx.Client) -> None: @@ -126,7 +126,7 @@ def test_buy_consumable_slots_full(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 1}), "BAD_REQUEST", - "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", + "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2. Use `use` to activate a consumable or `sell` to remove one.", ) def test_buy_vouchers_slot_empty(self, client: httpx.Client) -> None: @@ -137,7 +137,7 @@ def test_buy_vouchers_slot_empty(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"voucher": 0}), "BAD_REQUEST", - "No vouchers to redeem. Defeat boss blind to restock", + "No vouchers to redeem. Defeat boss blind to restock.", ) def test_buy_packs_slot_empty(self, client: httpx.Client) -> None: @@ -148,7 +148,7 @@ def test_buy_packs_slot_empty(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"pack": 0}), "BAD_REQUEST", - "No packs to open", + "No packs to open. Use `next_round` to advance to the next blind and restock the shop.", ) def test_buy_joker_success(self, client: httpx.Client) -> None: From 6458f3dacc93a2ead4dded18a056c2db0a6ddf44 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:39:15 +0100 Subject: [PATCH 003/121] test(lua.endpoints): fix test for vouchers effect --- tests/lua/endpoints/test_gamestate.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 4ada0313..730211dc 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -911,9 +911,12 @@ def test_voucher_effect_text( load_fixture( client, "gamestate", - "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE", + "state-SHOP", ) - response = api(client, "set", {"used_voucher": voucher_key}) + response = api(client, "add", {"key": voucher_key}) + gamestate = assert_gamestate_response(response) + assert gamestate["vouchers"]["cards"][1]["value"]["effect"] == expected_effect + response = api(client, "buy", {"voucher": 1}) gamestate = assert_gamestate_response(response) assert voucher_key in gamestate["used_vouchers"] assert gamestate["used_vouchers"][voucher_key] == expected_effect From c8e28c88a01302023e4a4bf1d449d7d088150389 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:40:11 +0100 Subject: [PATCH 004/121] refactor(lua.endpoints): us the SMODS.add_voucher_to_shop for add vouchers --- src/lua/endpoints/add.lua | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 0bee6a21..05b4f790 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -121,13 +121,13 @@ return { name = "add", - description = "Add a new card to the game (joker, consumable, voucher, or playing card)", + description = "Add a new card to the game (joker, consumable, voucher, pack, or playing card)", schema = { key = { type = "string", required = true, - description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, SUIT_RANK for playing cards like H_A)", + description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, p_* for packs, SUIT_RANK for playing cards like H_A)", }, seal = { type = "string", @@ -173,7 +173,7 @@ return { if not card_type then send_response({ - message = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + message = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), pack (p_*), or playing card (SUIT_RANK)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -378,12 +378,6 @@ return { if enhancement_value then params.enhancement = enhancement_value end - elseif card_type == "voucher" then - params = { - key = args.key, - area = G.shop_vouchers, - skip_materialize = true, - } else -- For jokers and consumables - just pass the key params = { @@ -429,6 +423,9 @@ return { if card_type == "pack" then -- Packs use dedicated SMODS function success, result = pcall(SMODS.add_booster_to_shop, args.key) + elseif card_type == "voucher" then + -- Vouchers use dedicated SMODS function + success, result = pcall(SMODS.add_voucher_to_shop, args.key) else -- Other cards use SMODS.add_card success, result = pcall(SMODS.add_card, params) From a979279a92c203f7db14d0cb2aead12c35f44591 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:41:01 +0100 Subject: [PATCH 005/121] feat: add support for Tags Closes #143. --- src/lua/utils/enums.lua | 26 ++++++++++++++++++ src/lua/utils/gamestate.lua | 54 +++++++++++++++++++++++++++++-------- src/lua/utils/openrpc.json | 44 +++++++++++++++++++++++------- src/lua/utils/types.lua | 9 +++++-- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 3d563de0..f6bbb6c5 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -411,3 +411,29 @@ ---| "UPCOMING" # Future blind ---| "DEFEATED" # Previously defeated blind ---| "SKIPPED" # Previously skipped blind + +---@alias Tag.Key +---| "tag_uncommon" # Uncommon Tag: Next Joker is Uncommon +---| "tag_rare" # Rare Tag: Next Joker is Rare +---| "tag_negative" # Negative Tag: Next Joker is Negative +---| "tag_foil" # Foil Tag: Next Joker is Foil +---| "tag_holo" # Holographic Tag: Next Joker is Holographic +---| "tag_polychrome" # Polychrome Tag: Next Joker is Polychrome +---| "tag_investment" # Investment Tag: Earn $25 when triggered +---| "tag_voucher" # Voucher Tag: Add a voucher to shop +---| "tag_boss" # Boss Tag: Next blind is a Boss +---| "tag_standard" # Standard Tag: Next shop has Standard Packs +---| "tag_charm" # Charm Tag: Create a Rare Joker +---| "tag_meteor" # Meteor Tag: Create a Spectral Pack +---| "tag_buffoon" # Buffoon Tag: Next shop has 4 Jokers +---| "tag_handy" # Handy Tag: Earn $1 per hand played +---| "tag_garbage" # Garbage Tag: Earn $1 per unused discard +---| "tag_ethereal" # Ethereal Tag: Create a Spectral Pack +---| "tag_coupon" # Coupon Tag: Next Joker is free +---| "tag_double" # Double Tag: Copies next Tag triggered +---| "tag_juggle" # Juggle Tag: +3 hand size next round +---| "tag_d_six" # D6 Tag: Reroll shop for free +---| "tag_top_up" # Top-up Tag: Create up to 2 Common Jokers +---| "tag_skip" # Skip Tag: Next skip gives extra money +---| "tag_orbital" # Orbital Tag: Upgrade random poker hand by 3 levels +---| "tag_economy" # Economy Tag: Earn $2 per $5 owned (max $40) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 6569eddb..359d76ef 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -652,6 +652,29 @@ local function get_tag_info(tag_key) return result end +---Gets all owned tags from G.GAME.tags +---@return Tag[] tags Array of Tag objects +local function get_owned_tags() + local tags = {} + + if not G or not G.GAME or not G.GAME.tags then + return tags + end + + for _, tag in pairs(G.GAME.tags) do + if tag and tag.key then + local tag_info = get_tag_info(tag.key) + table.insert(tags, { + key = tag.key, + name = tag_info.name, + effect = tag_info.effect, + }) + end + end + + return tags +end + ---Converts game blind status to uppercase enum ---@param status string Game status (e.g., "Defeated", "Current", "Select") ---@return string uppercase_status Uppercase status enum (e.g., "DEFEATED", "CURRENT", "SELECT") @@ -682,8 +705,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, big = { type = "BIG", @@ -691,8 +713,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, boss = { type = "BOSS", @@ -700,8 +721,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, } @@ -739,8 +759,11 @@ function gamestate.get_blinds_info() local small_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Small if small_tag_key then local tag_info = get_tag_info(small_tag_key) - blinds.small.tag_name = tag_info.name - blinds.small.tag_effect = tag_info.effect + blinds.small.tag = { + key = small_tag_key, + name = tag_info.name, + effect = tag_info.effect, + } end end @@ -763,8 +786,11 @@ function gamestate.get_blinds_info() local big_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Big if big_tag_key then local tag_info = get_tag_info(big_tag_key) - blinds.big.tag_name = tag_info.name - blinds.big.tag_effect = tag_info.effect + blinds.big.tag = { + key = big_tag_key, + name = tag_info.name, + effect = tag_info.effect, + } end end @@ -788,7 +814,7 @@ function gamestate.get_blinds_info() blinds.boss.score = math.floor(base_amount * 2 * ante_scaling) end - -- Boss blind has no tags (tag_name and tag_effect remain empty strings) + -- Boss blind has no tags (tag remains nil) return blinds end @@ -845,6 +871,12 @@ function gamestate.get_gamestate() state_data.used_vouchers = used_vouchers end + -- Owned tags (Tag[]) + local owned_tags = get_owned_tags() + if #owned_tags > 0 then + state_data.tags = owned_tags + end + -- Poker hands if G.GAME.hands then state_data.hands = extract_hand_info(G.GAME.hands) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index eaea0856..8638d251 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -37,7 +37,7 @@ { "name": "add", "summary": "Add a new card to the game", - "description": "Add a new card to the game (joker, consumable, voucher, or playing card). Playing cards use SUIT_RANK format (e.g., H_A for Ace of Hearts).", + "description": "Add a new card to the game (joker, consumable, voucher, pack, or playing card). Playing cards use SUIT_RANK format (e.g., H_A for Ace of Hearts).", "tags": [ { "$ref": "#/components/tags/cards" @@ -46,7 +46,7 @@ "params": [ { "name": "key", - "description": "Card key. Format: jokers (j_*), consumables (c_*), vouchers (v_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", + "description": "Card key. Format: jokers (j_*), consumables (c_*), vouchers (v_*), packs (p_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", "required": true, "schema": { "$ref": "#/components/schemas/CardKey" @@ -913,6 +913,13 @@ "type": "string" } }, + "tags": { + "type": "array", + "description": "Accumulated tags owned by the player", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, "hands": { "type": "object", "description": "Poker hands information", @@ -1053,6 +1060,29 @@ } } }, + "Tag": { + "type": "object", + "description": "Tag information", + "properties": { + "key": { + "type": "string", + "description": "The tag key (e.g., 'tag_polychrome')" + }, + "name": { + "type": "string", + "description": "Display name (e.g., 'Polychrome Tag')" + }, + "effect": { + "type": "string", + "description": "Description of the tag's effect" + } + }, + "required": [ + "key", + "name", + "effect" + ] + }, "Blind": { "type": "object", "description": "Blind information", @@ -1075,13 +1105,9 @@ "type": "integer", "description": "Score requirement to beat this blind" }, - "tag_name": { - "type": "string", - "description": "Name of the tag associated with this blind (Small/Big only)" - }, - "tag_effect": { - "type": "string", - "description": "Description of the tag's effect (Small/Big only)" + "tag": { + "$ref": "#/components/schemas/Tag", + "description": "Tag associated with this blind (Small/Big only)" } }, "required": [ diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 53f43b13..3f2f3854 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -17,6 +17,7 @@ ---@field ante_num integer Current ante number ---@field money integer Current money amount ---@field used_vouchers table? Vouchers used (name -> description) +---@field tags Tag[]? Accumulated tags owned by the player ---@field hands table? Poker hands information ---@field round Round? Current round state ---@field blinds table<"small"|"big"|"boss", Blind>? Blind information @@ -47,14 +48,18 @@ ---@field reroll_cost integer? Current cost to reroll the shop ---@field chips integer? Current chips scored in this round +---@class Tag +---@field key string The tag key (e.g., "tag_polychrome", "tag_double") +---@field name string Display name of the tag (e.g., "Polychrome Tag") +---@field effect string Description of the tag's effect + ---@class Blind ---@field type Blind.Type Type of the blind ---@field status Blind.Status Status of the bilnd ---@field name string Name of the blind (e.g., "Small", "Big" or the Boss name) ---@field effect string Description of the blind's effect ---@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 tag Tag? Tag associated with this blind (Small/Big only) ---@class Area ---@field count integer Current number of cards in this area From a83203b8b27976b46a769d65eeb7b09a9dd629e8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:41:53 +0100 Subject: [PATCH 006/121] test(lua.endpoints): add test for tags support --- tests/lua/endpoints/test_add.py | 2 +- tests/lua/endpoints/test_gamestate.py | 80 +++++++++++++++++++++++---- tests/lua/endpoints/test_skip.py | 7 +++ 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index fe11a58b..f482c64a 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -155,7 +155,7 @@ def test_invalid_key_unknown_format(self, client: httpx.Client) -> None: assert_error_response( api(client, "add", {"key": "x_unknown"}), "BAD_REQUEST", - "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), pack (p_*), or playing card (SUIT_RANK)", ) def test_invalid_key_known_format(self, client: httpx.Client) -> None: diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 730211dc..a4ba0536 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -148,24 +148,28 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: "name": "Small Blind", "effect": "", "score": 300, - "tag_effect": "Next base edition shop Joker is free and becomes Polychrome", - "tag_name": "Polychrome Tag", + "tag": { + "key": "tag_polychrome", + "name": "Polychrome Tag", + "effect": "Next base edition shop Joker is free and becomes Polychrome", + }, }, "big": { - "effect": "", + "type": "BIG", "name": "Big Blind", + "effect": "", "score": 450, - "tag_effect": "After defeating the Boss Blind, gain $25", - "tag_name": "Investment Tag", - "type": "BIG", + "tag": { + "key": "tag_investment", + "name": "Investment Tag", + "effect": "After defeating the Boss Blind, gain $25", + }, }, "boss": { - "effect": "-1 Hand Size", + "type": "BOSS", "name": "The Manacle", + "effect": "-1 Hand Size", "score": 600, - "tag_effect": "", - "tag_name": "", - "type": "BOSS", }, } actual_blinds = { @@ -922,6 +926,62 @@ def test_voucher_effect_text( assert gamestate["used_vouchers"][voucher_key] == expected_effect +class TestGamestateTags: + """Test gamestate Tag structure and owned_tags extraction.""" + + def test_blind_tag_structure(self, client: httpx.Client) -> None: + """Test blind tag has key, name, effect fields.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + gamestate = load_fixture(client, "gamestate", fixture_name) + + # Small blind should have a tag + small_tag = gamestate["blinds"]["small"]["tag"] + assert small_tag is not None + assert "key" in small_tag + assert "name" in small_tag + assert "effect" in small_tag + assert small_tag["key"] == "tag_polychrome" + assert small_tag["name"] == "Polychrome Tag" + assert "Polychrome" in small_tag["effect"] + + # Big blind should have a tag + big_tag = gamestate["blinds"]["big"]["tag"] + assert big_tag is not None + assert "key" in big_tag + assert "name" in big_tag + assert "effect" in big_tag + + # Boss blind should not have a tag + assert gamestate["blinds"]["boss"].get("tag") is None + + def test_tags_empty_initially(self, client: httpx.Client) -> None: + """Test tags is empty/not present at start of run.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + gamestate = load_fixture(client, "gamestate", fixture_name) + # tags should not be present when empty + assert "tags" not in gamestate + + def test_tags_populated_after_skip(self, client: httpx.Client) -> None: + """Test tags is populated after skipping a blind.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + load_fixture(client, "gamestate", fixture_name) + + # Skip the small blind to get its tag + response = api(client, "skip", {}) + gamestate = assert_gamestate_response(response) + + # Should now have tags + assert "tags" in gamestate + assert len(gamestate["tags"]) >= 1 + + # Check tag structure + tag = gamestate["tags"][0] + assert "key" in tag + assert "name" in tag + assert "effect" in tag + assert tag["key"].startswith("tag_") + + class TestGamestateCardModifiers: """Test gamestate card modifiers.""" diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 5a89edc8..cac95693 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -20,10 +20,12 @@ def test_skip_small_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["small"]["status"] == "SELECT" + assert "tags" not in gamestate response = api(client, "skip", {}) gamestate = assert_gamestate_response(response, state="BLIND_SELECT") assert gamestate["blinds"]["small"]["status"] == "SKIPPED" assert gamestate["blinds"]["big"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" def test_skip_big_blind(self, client: httpx.Client) -> None: """Test skipping Big blind in BLIND_SELECT state.""" @@ -32,10 +34,13 @@ def test_skip_big_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["big"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" response = api(client, "skip", {}) gamestate = assert_gamestate_response(response, state="BLIND_SELECT") assert gamestate["blinds"]["big"]["status"] == "SKIPPED" assert gamestate["blinds"]["boss"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" + assert "tag_investment" not in gamestate["tags"] # because it used immediately def test_skip_big_boss(self, client: httpx.Client) -> None: """Test skipping Boss in BLIND_SELECT state.""" @@ -44,6 +49,8 @@ def test_skip_big_boss(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["boss"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" + assert "tag_investment" not in gamestate["tags"] # because it used immediately assert_error_response( api(client, "skip", {}), "NOT_ALLOWED", From 0bcd06950d0c1d9a46086c969814a4b40c8cd597 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 12:19:30 +0100 Subject: [PATCH 007/121] docs(lua.utils): fix the description of the enums tags --- src/lua/utils/enums.lua | 46 ++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index f6bbb6c5..f236eff9 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -413,27 +413,27 @@ ---| "SKIPPED" # Previously skipped blind ---@alias Tag.Key ----| "tag_uncommon" # Uncommon Tag: Next Joker is Uncommon ----| "tag_rare" # Rare Tag: Next Joker is Rare ----| "tag_negative" # Negative Tag: Next Joker is Negative ----| "tag_foil" # Foil Tag: Next Joker is Foil ----| "tag_holo" # Holographic Tag: Next Joker is Holographic ----| "tag_polychrome" # Polychrome Tag: Next Joker is Polychrome ----| "tag_investment" # Investment Tag: Earn $25 when triggered ----| "tag_voucher" # Voucher Tag: Add a voucher to shop ----| "tag_boss" # Boss Tag: Next blind is a Boss ----| "tag_standard" # Standard Tag: Next shop has Standard Packs ----| "tag_charm" # Charm Tag: Create a Rare Joker ----| "tag_meteor" # Meteor Tag: Create a Spectral Pack ----| "tag_buffoon" # Buffoon Tag: Next shop has 4 Jokers ----| "tag_handy" # Handy Tag: Earn $1 per hand played ----| "tag_garbage" # Garbage Tag: Earn $1 per unused discard ----| "tag_ethereal" # Ethereal Tag: Create a Spectral Pack ----| "tag_coupon" # Coupon Tag: Next Joker is free ----| "tag_double" # Double Tag: Copies next Tag triggered +---| "tag_uncommon" # Uncommon Tag: Shop has a free Uncommon Joker +---| "tag_rare" # Rare Tag: Shop has a free Rare Joker +---| "tag_negative" # Negative Tag: Next base edition shop Joker is free and becomes Negative +---| "tag_foil" # Foil Tag: Next base edition shop Joker is free and becomes Foil +---| "tag_holo" # Holographic Tag: Next base edition shop Joker is free and becomes Holographic +---| "tag_polychrome" # Polychrome Tag: Next base edition shop Joker is free and becomes Polychrome +---| "tag_investment" # Investment Tag: Gain $25 after defeating the next Boss Blind +---| "tag_voucher" # Voucher Tag: Adds one Voucher to the next shop +---| "tag_boss" # Boss Tag: Rerolls the Boss Blind +---| "tag_standard" # Standard Tag: Gives a free Mega Standard Pack +---| "tag_charm" # Charm Tag: Gives a free Mega Arcana Pack +---| "tag_meteor" # Meteor Tag: Gives a free Mega Celestial Pack +---| "tag_buffoon" # Buffoon Tag: Gives a free Mega Buffoon Pack +---| "tag_handy" # Handy Tag: Gives $1 per played hand this run +---| "tag_garbage" # Garbage Tag: Gives $1 per unused discard this run +---| "tag_ethereal" # Ethereal Tag: Gives a free Spectral Pack +---| "tag_coupon" # Coupon Tag: Initial cards and booster packs in next shop are free +---| "tag_double" # Double Tag: Gives a copy of the next selected Tag (Double Tag excluded) ---| "tag_juggle" # Juggle Tag: +3 hand size next round ----| "tag_d_six" # D6 Tag: Reroll shop for free ----| "tag_top_up" # Top-up Tag: Create up to 2 Common Jokers ----| "tag_skip" # Skip Tag: Next skip gives extra money ----| "tag_orbital" # Orbital Tag: Upgrade random poker hand by 3 levels ----| "tag_economy" # Economy Tag: Earn $2 per $5 owned (max $40) +---| "tag_d_six" # D6 Tag: Rerolls in next shop start at $0 +---| "tag_top_up" # Top-up Tag: Create up to 2 Common Jokers (Must have room) +---| "tag_skip" # Skip Tag (aka Speed Tag): Gives $5 per skipped Blind this run +---| "tag_orbital" # Orbital Tag: Upgrade [poker hand] by 3 levels +---| "tag_economy" # Economy Tag: Doubles your money (Max of $40) From 02af87203282311b40934d573efd2410d400910d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 12:28:15 +0100 Subject: [PATCH 008/121] docs(api): add documentation for tags --- docs/api.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6f3ed22f..f0c40a94 100644 --- a/docs/api.md +++ b/docs/api.md @@ -698,6 +698,7 @@ The complete game state returned by most methods. "seed": "ABC123", "won": false, "used_vouchers": {}, + "tags": [ ... ], "hands": { ... }, "round": { ... }, "blinds": { ... }, @@ -780,8 +781,23 @@ Represents a card area (hand, jokers, consumables, shop, etc.). "name": "Small Blind", "effect": "No special effect", "score": 300, - "tag_name": "Uncommon Tag", - "tag_effect": "Shop has a free Uncommon Joker" + "tag": { + "key": "tag_juggle", + "name": "Juggle Tag", + "effect": "+3 hand size next round" + } +} +``` + +### Tag + +Represents a Balatro tag that provides bonuses when triggered. + +```json +{ + "key": "tag_juggle", + "name": "Juggle Tag", + "effect": "+3 hand size next round" } ``` @@ -925,6 +941,37 @@ Represents a card area (hand, jokers, consumables, shop, etc.). | `DEFEATED` | Previously beaten | | `SKIPPED` | Previously skipped | +### Tags + +Tags provide bonuses when triggered, typically after skipping a blind or defeating a boss blind. + +| Value | Description | +| ---------------- | ------------------------------------------------------------ | +| `tag_uncommon` | Shop has a free Uncommon Joker | +| `tag_rare` | Shop has a free Rare Joker | +| `tag_negative` | Next base edition shop Joker is free and becomes Negative | +| `tag_foil` | Next base edition shop Joker is free and becomes Foil | +| `tag_holo` | Next base edition shop Joker is free and becomes Holographic | +| `tag_polychrome` | Next base edition shop Joker is free and becomes Polychrome | +| `tag_investment` | Gain $25 after defeating the next Boss Blind | +| `tag_voucher` | Adds one Voucher to the next shop | +| `tag_boss` | Rerolls the Boss Blind | +| `tag_standard` | Gives a free Mega Standard Pack | +| `tag_charm` | Gives a free Mega Arcana Pack | +| `tag_meteor` | Gives a free Mega Celestial Pack | +| `tag_buffoon` | Gives a free Mega Buffoon Pack | +| `tag_handy` | Gives $1 per played hand this run | +| `tag_garbage` | Gives $1 per unused discard this run | +| `tag_ethereal` | Gives a free Spectral Pack | +| `tag_coupon` | Initial cards and booster packs in next shop are free | +| `tag_double` | Gives a copy of the next selected Tag (Double Tag excluded) | +| `tag_juggle` | +3 hand size next round | +| `tag_d_six` | Rerolls in next shop start at $0 | +| `tag_top_up` | Create up to 2 Common Jokers (Must have room) | +| `tag_skip` | Gives $5 per skipped Blind this run | +| `tag_orbital` | Upgrade [poker hand] by 3 levels | +| `tag_economy` | Doubles your money (Max of $40) | + ### Card Keys Card keys are used with the `add` method and appear in the `key` field of Card objects. From f7fda7b8b862481cab1a36175a4ac37849a5ca0c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 13:07:56 +0100 Subject: [PATCH 009/121] fix: allow to sell jokers when a Buffoon pack is open Closes #156. --- docs/api.md | 4 ++-- src/lua/endpoints/sell.lua | 24 ++++++++++++++++++++++-- src/lua/utils/openrpc.json | 5 ++++- tests/lua/endpoints/test_pack.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index f0c40a94..c93b7fc1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -395,7 +395,7 @@ curl -X POST http://127.0.0.1:12346 \ ### `sell` -Sell a joker or consumable. +Sell a joker or consumable. Available in SHOP, SELECTING_HAND states, and when a Buffoon pack is open (to make room for new jokers). **Parameters:** (exactly one required) @@ -406,7 +406,7 @@ Sell a joker or consumable. **Returns:** [GameState](#gamestate-schema) -**Errors:** `BAD_REQUEST`, `NOT_ALLOWED` +**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED` **Example:** diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 5d112222..58593f1c 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -32,7 +32,7 @@ return { }, }, - requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP, G.STATES.SMODS_BOOSTER_OPENED }, ---@param args Request.Endpoint.Sell.Params ---@param send_response fun(response: Response.Endpoint) @@ -55,6 +55,22 @@ return { return end + -- If in SMODS_BOOSTER_OPENED, verify it's a Buffoon pack (contains Jokers) + if G.STATE == G.STATES.SMODS_BOOSTER_OPENED then + local pack_set = 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 + if pack_set ~= "Joker" then + send_response({ + message = "Can only sell jokers when a Buffoon pack is open", + name = BB_ERROR_NAMES.NOT_ALLOWED, + }) + return + end + end + -- Determine which type to sell and validate existence local source_array, pos, sell_type @@ -144,7 +160,11 @@ return { local state_stable = G.STATE_COMPLETE == true -- 5. Still in valid state - local valid_state = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND) + local valid_state = ( + G.STATE == G.STATES.SHOP + or G.STATE == G.STATES.SELECTING_HAND + or G.STATE == G.STATES.SMODS_BOOSTER_OPENED + ) -- All conditions must be met if count_decreased and money_increased and card_gone and state_stable and valid_state then diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 8638d251..5d272ae1 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -577,7 +577,7 @@ { "name": "sell", "summary": "Sell a joker or consumable", - "description": "Sell a joker or consumable from player inventory. Must provide exactly one of: joker or consumable.", + "description": "Sell a joker or consumable from player inventory. Must provide exactly one of: joker or consumable. Available in SHOP, SELECTING_HAND states, and when a Buffoon pack is open (SMODS_BOOSTER_OPENED state with Joker set pack) to make room for new jokers.", "tags": [ { "$ref": "#/components/tags/shop" @@ -614,6 +614,9 @@ { "$ref": "#/components/errors/BadRequest" }, + { + "$ref": "#/components/errors/InvalidState" + }, { "$ref": "#/components/errors/NotAllowed" } diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 1ff2a514..0e60555b 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -158,6 +158,35 @@ def test_pack_joker_slots_full(self, client: httpx.Client) -> None: "Cannot select joker, joker slots are full. Current: 5, Limit: 5", ) + def test_pack_joker_slots_full_sell_joker(self, client: httpx.Client) -> None: + """Test selling a joker to make room when joker slots are full during pack selection.""" + gamestate = load_fixture( + client, + "pack", + "state-SMODS_BOOSTER_OPENED--pack.type-buffoon--jokers.count-5", + ) + assert gamestate["jokers"]["count"] == 5 + before_jokers = set(j["key"] for j in gamestate["jokers"]["cards"]) + result = api(client, "sell", {"joker": 0}) + gamestate = assert_gamestate_response(result) + assert gamestate["jokers"]["count"] == 4 + result = api(client, "pack", {"card": 0}) + gamestate = assert_gamestate_response(result, state="SHOP") + assert gamestate["jokers"]["count"] == 5 + after_jokers = set(j["key"] for j in gamestate["jokers"]["cards"]) + assert before_jokers != after_jokers + + def test_pack_tarot_try_to_sell_joker(self, client: httpx.Client) -> None: + """Test that selling jokers is not allowed when a non-buffoon pack is open.""" + load_fixture( + client, "pack", "state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_heirophant" + ) + assert_error_response( + api(client, "sell", {"joker": 0}), + "NOT_ALLOWED", + "Can only sell jokers when a Buffoon pack is open", + ) + def test_pack_joker_slots_available(self, client: httpx.Client) -> None: """Test selecting joker when slots available succeeds.""" load_fixture( From 2a0b9bbafbd574254c53edf0a566be1dc5265ca8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 28 Feb 2026 14:23:23 +0100 Subject: [PATCH 010/121] test(lua.endpoints): fix the assertion for the tags tests --- tests/lua/endpoints/test_skip.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index cac95693..1ca8179d 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -34,13 +34,14 @@ def test_skip_big_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["big"]["status"] == "SELECT" - assert gamestate["tags"][0]["key"] == "tag_polychrome" + assert {"tag_polychrome"} == set(k["key"] for k in gamestate["tags"]) response = api(client, "skip", {}) gamestate = assert_gamestate_response(response, state="BLIND_SELECT") assert gamestate["blinds"]["big"]["status"] == "SKIPPED" assert gamestate["blinds"]["boss"]["status"] == "SELECT" - assert gamestate["tags"][0]["key"] == "tag_polychrome" - assert "tag_investment" not in gamestate["tags"] # because it used immediately + assert {"tag_polychrome", "tag_investment"} == set( + k["key"] for k in gamestate["tags"] + ) def test_skip_big_boss(self, client: httpx.Client) -> None: """Test skipping Boss in BLIND_SELECT state.""" @@ -50,7 +51,9 @@ def test_skip_big_boss(self, client: httpx.Client) -> None: assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["boss"]["status"] == "SELECT" assert gamestate["tags"][0]["key"] == "tag_polychrome" - assert "tag_investment" not in gamestate["tags"] # because it used immediately + assert {"tag_polychrome", "tag_investment"} == set( + k["key"] for k in gamestate["tags"] + ) assert_error_response( api(client, "skip", {}), "NOT_ALLOWED", From b0fd730d7d1f898742c05bb01d4aa5cc0f0a8d1d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:41:17 +0200 Subject: [PATCH 011/121] chore(repo): replace Claude/CLI tooling with agent-agnostic config - Remove .claude/ directory (settings.json, skills/balatrobot/SKILL.md) - Remove CLAUDE.md in favor of AGENTS.md - Remove .mux/ directory (init, mcp.jsonc, tool_env, tool_post) - Remove .mdformat.toml (flags moved to Makefile) - Add AGENTS.md with project structure and rules - Add CONTEXT.md with glossary of domain terms - Add .agents/skills/balatrobot/SKILL.md for pi skill --- .agents/skills/balatrobot/SKILL.md | 37 +++++++ .claude/settings.json | 26 ----- .claude/skills/balatrobot/SKILL.md | 169 ---------------------------- .mdformat.toml | 5 - .mux/init | 43 -------- .mux/mcp.jsonc | 13 --- .mux/tool_env | 5 - .mux/tool_post | 12 -- AGENTS.md | 38 +++++++ CLAUDE.md | 171 ----------------------------- CONTEXT.md | 30 +++++ 11 files changed, 105 insertions(+), 444 deletions(-) create mode 100644 .agents/skills/balatrobot/SKILL.md delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/balatrobot/SKILL.md delete mode 100644 .mdformat.toml delete mode 100755 .mux/init delete mode 100644 .mux/mcp.jsonc delete mode 100644 .mux/tool_env delete mode 100755 .mux/tool_post create mode 100644 AGENTS.md delete mode 100644 CLAUDE.md create mode 100644 CONTEXT.md diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md new file mode 100644 index 00000000..7c45e84b --- /dev/null +++ b/.agents/skills/balatrobot/SKILL.md @@ -0,0 +1,37 @@ +--- +name: balatrobot +description: Launch Balatro with the BalatroBot mod and interact via the CLI. Use when you need to manually test, reproduce issues, or inspect game state through the JSON-RPC API. +--- + +# BalatroBot CLI runbook + +Run commands from the repo root. Use `balatrobot ...` only (no `curl`, no `uvx`). +Help is available with `balatrobot --help`. + +## Start a session + +Pick a random port in 20000–30000 to avoid conflicts: + +```bash +PORT="$((20000 + RANDOM % 10001))" +balatrobot serve --port "$PORT" --headless --fast --debug +``` + +Help is available with `balatrobot serve --help`. +Use `--render-on-api` instead of `--headless` when you need screenshots. + +## Call the API (in a second terminal) + +```bash +balatrobot api health --port "$PORT" +balatrobot api gamestate --port "$PORT" +balatrobot api start '{"deck":"RED","stake":"WHITE"}' --port "$PORT" +balatrobot api select --port "$PORT" +balatrobot api play '{"cards":[0,1,2,3,4]}' --port "$PORT" +balatrobot api menu --port "$PORT" +``` + +Help is available with `balatrobot serve --help`. +Pipe to `jq` to filter responses. Example: `balatrobot api gamestate --port "$PORT" | jq '.state'`. + +Full API reference: `docs/api.md`. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 34b95fa0..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(pytest:*)", - "Bash(make:*)" - ], - "deny": [ - "Edit(CHANGELOG.md)", - "Write(CHANGELOG.md)" - ] - }, - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "make quality", - "timeout": 5 - } - ] - } - ] - } -} diff --git a/.claude/skills/balatrobot/SKILL.md b/.claude/skills/balatrobot/SKILL.md deleted file mode 100644 index 2ff66237..00000000 --- a/.claude/skills/balatrobot/SKILL.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -name: balatrobot -description: Run and debug BalatroBot locally. Use when you need to start Balatro with the BalatroBot Lua mod, manually test or reproduce issues via the JSON-RPC HTTP API, inspect the newest session logs under logs/, and capture screenshots into logs//artifacts/ using only the balatrobot CLI (no curl, no uvx). -allowed-tools: Bash(balatrobot:*) Bash(mkdir:*) Bash(ls:*) Bash(tail:*) Bash(PORT=:*) Bash(echo:*) Bash(jq:*) Bash(sleep:*) Read Grep -disable-model-invocation: true ---- - -# BalatroBot debug/runbook - -## Ground rules - -- Run commands from the repo root. -- Use `balatrobot ...` only (no `uvx`, no `curl`). -- Run `balatrobot api ...` requests sequentially (avoid concurrent calls). -- Prefer minimal, targeted changes while debugging; avoid large refactors. - -## Start BalatroBot - -### Choose a port (recommended) - -Pick a random port in the range `20000-30000` to avoid the port staying busy for ~20-30s after restarting: - -```bash -PORT="$((20000 + RANDOM % 10001))" -echo "Using PORT=$PORT" -``` - -Pick a new random `PORT` every time you restart `balatrobot serve` (crash/restart/kill/start). - -### Default profile (most cases) - -Use headless mode unless explicitly asked to take screenshots: - -```bash -balatrobot serve --port "$PORT" --headless --fast --debug -``` - -### Screenshot profile (only when explicitly asked) - -Use render-on-API mode for deterministic screenshots: - -```bash -balatrobot serve --port "$PORT" --render-on-api --fast --debug -``` - -## Call the API (no curl) - -Run API calls in a second terminal while `balatrobot serve ...` is running. - -Reminder: wrap JSON params in single quotes so you don't need to escape JSON double quotes. - -```bash -# Health check -balatrobot api health --port "$PORT" - -# Get full game state -balatrobot api gamestate --port "$PORT" - -# Return to menu -balatrobot api menu --port "$PORT" - -# Start a new run -balatrobot api start '{"deck":"RED","stake":"WHITE"}' --port "$PORT" - -# Select blind -balatrobot api select --port "$PORT" - -# Play cards (0-based indices) -balatrobot api play '{"cards":[0,1,2,3,4]}' --port "$PORT" -``` - -### Filter responses with `jq` (optional) - -The `balatrobot api ...` CLI prints the JSON-RPC `result` object to stdout on success (see `docs/api.md`), so `jq` filters target top-level fields like `.state` (not `.result.state`). - -```bash -# Print the current state as a raw string (MENU / BLIND_SELECT / SELECTING_HAND / ...) -balatrobot api gamestate --port "$PORT" | jq -r '.state' - -# Quick summary (useful for bug reports) -balatrobot api gamestate --port "$PORT" | jq '{state, round_num, ante_num, money, deck, stake, won}' - -# Round counters (hands/discards/chips) -balatrobot api gamestate --port "$PORT" | jq '.round | {hands_left, discards_left, chips, reroll_cost}' - -# Cards currently in hand (ids + labels) -balatrobot api gamestate --port "$PORT" | jq '.hand.cards | map({id, key, label})' - -# Joker labels (one per line) -balatrobot api gamestate --port "$PORT" | jq -r '.jokers.cards[].label' -``` - -On failure, `balatrobot api ...` prints a human-readable error to stderr and exits non-zero. To extract fields from that error output, capture stderr and use `jq` raw mode: - -```bash -balatrobot api play '{"cards":[999]}' --port "$PORT" 2>&1 >/dev/null \ - | jq -R 'capture("^Error: (?.*?) - (?.*)$")' -``` - -Always pass the same `--port` to both `serve` and `api`. - -## Logs and sessions - -### Session layout - -Each `balatrobot serve ...` run creates: - -- `logs//.log` (game + mod stdout/stderr) - -`` is a timestamp folder like `2025-12-29T19-18-18` (lexicographically sortable). - -### Find the current session - -Pick the newest session directory (works because of the timestamp format): - -```bash -SESSION="$(ls -1 logs | sort | tail -n 1)" -``` - -Use it to open/tail the log: - -```bash -tail -f "logs/$SESSION/$PORT.log" -``` - -The log filename matches the port: `logs//.log`. - -## Screenshots (write to logs//artifacts/) - -Only do this when explicitly asked for a screenshot. - -1. Start the server with the screenshot profile: - - ```bash - balatrobot serve --port "$PORT" --render-on-api --fast --debug - ``` - -2. Create the artifacts directory under the newest session: - - ```bash - SESSION="$(ls -1 logs | sort | tail -n 1)" - mkdir -p "logs/$SESSION/artifacts" - ``` - -3. Call the screenshot endpoint with an absolute path inside that folder: - - ```bash - balatrobot api screenshot "{\"path\":\"$(pwd)/logs/$SESSION/artifacts/screenshot.png\"}" --port "$PORT" - ``` - -## Debug workflow (tight loop) - -1. Reproduce with the smallest possible sequence of `balatrobot api ...` calls. -2. Capture: - - the exact `balatrobot serve ...` command, - - host/port, - - the session folder name, - - relevant excerpts from `logs//.log`, - - the JSON outputs (stdout) and errors (stderr) from `balatrobot api ...`. -3. Read the most relevant code before changing anything. -4. If needed, add minimal logging close to the suspected behavior, then re-run. - -## Where to look in the repo - -- CLI entry points: `src/balatrobot/cli/serve.py`, `src/balatrobot/cli/api.py` -- Game process + log session creation: `src/balatrobot/manager.py` -- Lua HTTP server + dispatcher: `src/lua/core/server.lua`, `src/lua/core/dispatcher.lua` -- API reference/spec: `docs/api.md`, `src/lua/utils/openrpc.json` -- Endpoint implementations: `src/lua/endpoints/*.lua` diff --git a/.mdformat.toml b/.mdformat.toml deleted file mode 100644 index 63865ff6..00000000 --- a/.mdformat.toml +++ /dev/null @@ -1,5 +0,0 @@ -wrap = "keep" -number = true -end_of_line = "lf" -validate = true -exclude = ["balatro/**", "CHANGELOG.md", ".venv/**", "smods.wiki/**"] diff --git a/.mux/init b/.mux/init deleted file mode 100755 index c28bf01c..00000000 --- a/.mux/init +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "Runtime: $MUX_RUNTIME" -echo "Project path: $MUX_PROJECT_PATH" -echo "Workspace: $PWD" - -if [ "$MUX_RUNTIME" = "ssh" ]; then - echo "SSH runtime not supported" - exit 1 -fi - -if [ "$MUX_RUNTIME" != "local" ]; then - # Copy .envrc from project root - if [ -f "../.envrc" ]; then - echo "Copying .envrc from parent..." - cp "../.envrc" ".envrc" - elif [ -n "$MUX_PROJECT_PATH" ] && [ -f "$MUX_PROJECT_PATH/.envrc" ]; then - echo "Copying .envrc from project root..." - cp "$MUX_PROJECT_PATH/.envrc" ".envrc" - fi - - # Copy .luarc.json for Lua language server - if [ -f "../.luarc.json" ]; then - echo "Copying .luarc.json from parent..." - cp "../.luarc.json" ".luarc.json" - elif [ -n "$MUX_PROJECT_PATH" ] && [ -f "$MUX_PROJECT_PATH/.luarc.json" ]; then - echo "Copying .luarc.json from project root..." - cp "$MUX_PROJECT_PATH/.luarc.json" ".luarc.json" - fi -else - echo "Local mode: using existing config files" -fi - -echo "Setting up Python environment..." -uv sync --group dev --group test - -if [ -f ".envrc" ]; then - echo "Sourcing .envrc..." - source .envrc -fi - -echo "Init complete!" diff --git a/.mux/mcp.jsonc b/.mux/mcp.jsonc deleted file mode 100644 index 46c666f5..00000000 --- a/.mux/mcp.jsonc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "servers": { - "context7": { - "transport": "http", - "url": "https://mcp.context7.com/mcp", - "headers": { - "CONTEXT7_API_KEY": { - "secret": "CONTEXT7_API_KEY" - } - } - } - } -} diff --git a/.mux/tool_env b/.mux/tool_env deleted file mode 100644 index 3f5a6134..00000000 --- a/.mux/tool_env +++ /dev/null @@ -1,5 +0,0 @@ -# Sourced before every bash tool call -# Activate venv and load environment variables - -source .venv/bin/activate 2>/dev/null || true -source .envrc 2>/dev/null || true diff --git a/.mux/tool_post b/.mux/tool_post deleted file mode 100755 index aee67383..00000000 --- a/.mux/tool_post +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Runs after every tool execution - -# Run quality checks after Python or Lua file edits -if [[ "$MUX_TOOL" == file_edit_* ]]; then - file=$(echo "$MUX_TOOL_INPUT" | jq -r '.file_path') - - if [[ "$file" == *.py ]] || [[ "$file" == *.lua ]]; then - echo "Running quality checks..." >&2 - make quality 2>&1 || exit 1 - fi -fi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4502d022 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +Project instructions for coding agents working on BalatroBot. + +## What is this + +BalatroBot is a [Balatro](https://www.playbalatro.com/) mod that exposes a JSON-RPC 2.0 HTTP API for external bot control. Two codebases: + +- **CLI** (`src/balatrobot/`) — Python package. Launches and manages the Balatro process. +- **Mod** (`src/lua/`) — Lua code injected into Balatro via Lovely/SMODS. Serves the API. + +## Project structure + +``` +src/balatrobot/ # Python CLI + game process manager +src/lua/core/ # HTTP server, dispatcher, validator +src/lua/endpoints/ # API endpoint modules (one file per endpoint) +src/lua/utils/ # Types, enums, errors, gamestate extraction, OpenRPC spec +tests/cli/ # Tests for the Python package +tests/lua/ # Tests for the Lua API (start real Balatro instances) +docs/ # Public documentation (api.md, cli.md, contributing.md) +``` + +## Rules + +- Use `make` commands for all tooling (`make help` to list them). Do not run bare `ruff`, `ty`, `mdformat`, etc. +- `tests/cli` and `tests/lua` must run separately. Never `pytest tests`. +- `CHANGELOG.md` is auto-generated by release-please. Do not edit it manually. +- See `CONTEXT.md` for project terminology. + +## Further reading + +- `CONTEXT.md` — glossary and domain terms +- `docs/api.md` — full API reference +- `docs/contributing.md` — dev environment setup, PR guidelines +- `src/lua/utils/openrpc.json` — machine-readable API spec +- `src/lua/utils/types.lua` — type definitions and endpoint schemas +- `src/lua/utils/enums.lua` — all enum values (states, card types, etc.) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 85f7cbd8..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,171 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -**GitHub Repository**: [`coder/balatrobot`](https://github.com/coder/balatrobot) - -## Overview - -BalatroBot is a framework for Balatro bot development. It consists of two main parts: - -1. **Python Package** (`src/balatrobot/`): A CLI and library to manage the Balatro game process, inject the mod, and handle communication. -2. **Lua API** (`src/lua/`): The mod code running inside Balatro (Love2D) that exposes a HTTP JSON-RPC 2.0 API. - -### Testing - -Integration tests (`tests/lua`) automatically start and stop Balatro instances on random ports. - -```bash -# Run all tests (CLI and Lua suites) -make test - -# Run Lua integration tests in parallel -pytest -n 6 tests/lua - -# Run CLI tests -pytest tests/cli - -# Run specific tests -pytest tests/lua/endpoints/test_health.py -v -pytest tests/lua/endpoints/test_health.py::TestHealthEndpoint::test_health_from_MENU -v - -# Run only integration tests -pytest tests/cli -m integration - -# Run non-integration tests (no Balatro instance required) -pytest tests/cli -m "not integration" - -# Manual launch for dev/debugging -balatrobot --fast --debug -``` - -### Make Commands - -Available make targets: - -| Target | Description | -| ---------------- | ------------------------------------------------------- | -| `make help` | Show all available targets | -| `make lint` | Run ruff linter (check only) | -| `make format` | Run ruff and mdformat formatters | -| `make typecheck` | Run type checker (Python and Lua) | -| `make quality` | Run all code quality checks (lint + typecheck + format) | -| `make test` | Run all tests | -| `make all` | Run all quality checks and tests | -| `make fixtures` | Generate test fixtures | -| `make install` | Install dependencies | - -**Important rules:** - -1. **Only run make commands when explicitly asked.** Do not proactively run `make test`, `make quality`, etc. -2. **Never run bare linting/formatting/typechecking tools.** Always use make targets instead: - - Use `make lint` instead of `ruff check` - - Use `make format` instead of `ruff format` - - Use `make typecheck` instead of `ty check` - - Use `make quality` for all checks combined - -## Architecture - -### 1. Python Layer (`src/balatrobot/`) - -Controls the game lifecycle and provides the CLI. - -- **CLI** (`cli.py`): Entry point (`balatrobot`). Handles arguments like `--fast`, `--debug`, `--headless`. -- **Manager** (`manager.py`): `BalatroInstance` context manager. Starts the game process, handles logging, and waits for the API to be healthy. -- **Config** (`config.py`): Configuration management using `dataclasses` and environment variables. -- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, Linux (Proton), and native Love2D. - -### 2. Lua Layer (`src/lua/`) - -Runs inside the game engine and exposes an API. - -- **HTTP Server** (`src/lua/core/server.lua`) - - - Single-client HTTP/1.1 server on port 12346 (default) - - **Protocol**: JSON-RPC 2.0 over HTTP POST to `/` - - **Request**: `{"jsonrpc": "2.0", "method": "endpoint", "params": {...}, "id": 1}` - - **Response**: `{"jsonrpc": "2.0", "result": {...}, "id": 1}` - - Max body size: 64KB - -- **Dispatcher** (`src/lua/core/dispatcher.lua`) - - - Routes requests based on the `method` field. - - Validates: - 1. Protocol (JSON-RPC 2.0, valid ID) - 2. Schema (via `validator.lua`) - 3. Game State (`requires_state`) - 4. Endpoint execution - -- **Endpoints** (`src/lua/endpoints/*.lua`) - - - Stateless modules defining `schema` and `execute` functions. - - 0-based indexing in API vs 1-based in Lua. - - OpenRPC Specification (`src/lua/utils/openrpc.json`): Machine-readable API documentation describing all endpoints. - - **Core Endpoints:** - - - `add.lua`: Add a new card (joker, consumable, voucher, playing card, or booster pack). - - `buy.lua`: Buy a card or booster pack from the shop. - - `cash_out.lua`: Cash out and collect round rewards. - - `discard.lua`: Discard cards from the hand. - - `gamestate.lua`: Get current game state. - - `health.lua`: Health check endpoint for connection testing. - - `load.lua`: Load a saved run state from a file. - - `menu.lua`: Return to the main menu from any game state. - - `next_round.lua`: Leave the shop and advance to blind selection. - - `pack.lua`: Select or skip a card from an opened booster pack. - - `play.lua`: Play a card from the hand. - - `rearrange.lua`: Rearrange cards in hand, jokers, or consumables. - - `reroll.lua`: Reroll to update the cards in the shop area. - - `save.lua`: Save the current run state to a file. - - `screenshot.lua`: Take a screenshot of the current game state. - - `select.lua`: Select the current blind. - - `sell.lua`: Sell a joker or consumable from player inventory. - - `set.lua`: Set a in-game value (money, chips, ante, etc.). - - `skip.lua`: Skip the current blind (Small or Big only). - - `start.lua`: Start a new game run with specified deck and stake. - - `use.lua`: Use a consumable card with optional target cards. - - **Test Endpoints (`src/lua/endpoints/tests/*.lua`):** - - - `echo.lua`: Test endpoint for dispatcher testing. - - `endpoint.lua`: Test endpoint with schema for dispatcher testing. - - `error.lua`: Test endpoint that throws runtime errors. - - `state.lua`: Test endpoint that requires specific game states. - - `validation.lua`: Comprehensive validation test endpoint. - -## Key Files - -- **Python**: - - `src/balatrobot/cli.py`: Main entry point. - - `src/balatrobot/manager.py`: Game process logic. -- **Lua**: - - `balatrobot.lua`: Mod entry point. - - `src/lua/core/server.lua`: HTTP/TCP handling. - - `src/lua/endpoints/`: All API commands. -- **Configuration**: - - `pyproject.toml`: Python dependencies and tools config. - - `balatrobot.json` / `balatrobot.lua`: SMODS mod metadata. - -### Error Handling - -Error codes are mapped to JSON-RPC standard and custom ranges: - -- `INTERNAL_ERROR` (-32000): Runtime errors -- `BAD_REQUEST` (-32001): Invalid schema or parameters -- `INVALID_STATE` (-32002): Action not allowed in current game state -- `NOT_ALLOWED` (-32003): Action prevented by game rules - -Error responses follow JSON-RPC 2.0 format: - -```json -{ - "jsonrpc": "2.0", - "error": { - "code": -32001, - "message": "Human readable error", - "data": { "name": "BAD_REQUEST" } - }, - "id": 1 -} -``` diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..5a899655 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,30 @@ +# CONTEXT.md + +Glossary of terms used in the BalatroBot project. + +| Term | Meaning | +|------|---------| +| **Balatro** | The commercial game by LocalThunk. Runs on Love2D. | +| **BalatroBot** | This project. A framework for Balatro bot development. | +| **CLI** (`src/balatrobot/`) | The Python package. Manages the Balatro process and provides the `balatrobot` command. | +| **Lua** or **mod** (`src/lua/`) | The SMODS mod injected into Balatro via Lovely. Serves a JSON-RPC 2.0 HTTP API. | +| **SMODS** | Balatro modding framework. Provides the API for creating mods (jokers, consumables, etc.). [github.com/Steamodded/smods](https://github.com/Steamodded/smods) | +| **Lovely** | Injection layer that loads mods into Balatro's Love2D process. [github.com/ethangreen-dev/lovely-injector](https://github.com/ethangreen-dev/lovely-injector) | +| **DebugPlus** | Optional SMODS mod for debug logging and UI. Required for the `--debug` flag. [github.com/WilsontheWolf/DebugPlus](https://github.com/WilsontheWolf/DebugPlus) | +| **OpenRPC spec** | Machine-readable API specification in `src/lua/utils/openrpc.json`. Describes all endpoints, params, and responses. [open-rpc.org](https://open-rpc.org/) | +| **game state** / **state** / **gamestate** | Ambiguous by design — meaning is clear from context. Can refer to: (1) the current game phase (e.g. `BLIND_SELECT`, `SHOP`), or (2) the JSON object returned by the API endpoints (a full snapshot of the game - see ./src/lua/utils/types.lua for fields). | +| **run** | A full game from start to win or loss. Progresses through antes (1–8 by default). | +| **ante** | A set of 3 rounds within a run: small blind, big blind, boss blind. Runs go from ante 1 to ante 8. | +| **round** | A single blind within a run. Each ante has 3 rounds. | +| **blind** | Overloaded by design — clear from context. Can refer to: (1) the game phase `BLIND_SELECT` where you choose a blind, (2) the specific blind type (small/big/boss), or (3) the blind entity with its name, effect, and score requirement. | +| **playing card** | A standard 52-card deck card (suit + rank). Can hold modifiers: seal, edition, enhancement. | +| **joker** | A modifier card sitting in the joker area. Provides ongoing scoring effects. | +| **consumable** | A one-time-use card: tarot, planet, or spectral. **Note:** Balatro's source code misspells this as `consumeables` — our API uses the correct spelling `consumables`. | +| **voucher** | A permanent upgrade purchased in the shop. Persists for the entire run. | +| **hand** | The cards currently dealt to the player (up to 8). An `Area` in the gamestate response. | +| **hands** | Poker hand information dictionary (pair, flush, straight, etc.). Tracks level, chips, mult, times played. | +| **area** | A card container in the gamestate (jokers, consumables, hand, cards, pack, shop, vouchers, packs). Each has `count`, `limit`, and `cards`. | +| **pack** / **booster** / **booster pack** | Same thing. A purchasable pack of cards you open and choose from. | +| **test fixture** | A JSON file of API call sequences that reproduces a specific game state. Not a pytest fixture. Generated by `make fixtures` and loaded by tests. | +| **`dev` marker** | `@pytest.mark.dev` — tags tests currently being developed. Run with `pytest -m dev` to isolate. Remove when done. Ephemeral, not permanent. | +| **endpoint** | A single API operation (e.g. `play`, `start`, `health`). Each endpoint is a Lua module in `src/lua/endpoints/`. Called "method" in JSON-RPC contexts and exposed as `balatrobot api ` in the CLI. | From adffe20297d5d9f1d14eae7f581106f697d448a0 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:41:25 +0200 Subject: [PATCH 012/121] chore(repo): slim down .gitignore to project-relevant entries Replace verbose boilerplate with minimal, curated entries covering macOS, Python, Lua, and project-specific ignores. --- .gitignore | 304 ++--------------------------------------------------- 1 file changed, 8 insertions(+), 296 deletions(-) diff --git a/.gitignore b/.gitignore index b291817f..c0614f16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,333 +1,45 @@ ################################################################################ -# MacOS +# macOS ################################################################################ -# General .DS_Store -__MACOSX/ -.AppleDouble -.LSOverride -Icon[ ] - -# Thumbnails ._* -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - ################################################################################ # Python ################################################################################ -# Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] +*.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +site/ *.egg-info/ -.installed.cfg *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - -# Environments +.venv/ .env .envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy +.pytest_cache/ .mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: .ruff_cache/ - -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Streamlit -.streamlit/secrets.toml +.coverage ################################################################################ # Lua ################################################################################ -# Lua Language Server .luarc.json -# Compiled Lua sources -luac.out - -# luarocks build files -*.src.rock -*.zip -*.tar.gz - -# Object files -*.o -*.os -*.ko -*.obj -*.elf - -# Precompiled Headers -*.gch -*.pch - -# Libraries -*.lib -*.a -*.la -*.lo -*.def -*.exp - -# Shared objects (inc. Windows DLLs) -*.dll -*.so -*.so.* -*.dylib - -# Executables -*.exe -*.out -*.app -*.i*86 -*.x86_64 -*.hex - ################################################################################ -# Other files +# Project ################################################################################ -# smods -smods -smods.wiki - -# lovely dump -dump - -# balatro -balatro +vendors/ *.jkr -# balatrobot runs/*.jsonl - -# logs logs/ - -################################################################################ -# Legacy files -################################################################################ - -src/lua_old -src/lua_oldish - -tests/lua_old -balatrobot_old.lua -balatrobot_oldish.lua - -balatro_oldish.sh -balatro.sh From 9e27f4ef9f09f063892db8871519d6ae41ec5b0a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:41:31 +0200 Subject: [PATCH 013/121] chore(build): move mdformat flags from .mdformat.toml to Makefile Inline --number and --exclude flags since the config file was removed. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c916b068..0d54dd2f 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ format: ## Run formatters (ruff, mdformat, stylua) ruff check --select I --fix . ruff format . @$(PRINT) "$(YELLOW)Running mdformat formatter...$(RESET)" - mdformat ./docs README.md CLAUDE.md .claude/skills/balatrobot/SKILL.md + mdformat --number --exclude "CHANGELOG.md" --exclude ".venv/**" --exclude "vendors/**" ./docs README.md .agents/skills/balatrobot/SKILL.md @if command -v stylua >/dev/null 2>&1; then \ $(PRINT) "$(YELLOW)Running stylua formatter...$(RESET)"; \ stylua src/lua; \ From ee3c1059a8b0fc114df1aba28854ecc5637d895b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:41:37 +0200 Subject: [PATCH 014/121] chore(build): bump ruff to 0.15.14 and ty to 0.0.40 Remove integration marker from pyproject.toml markers config. --- pyproject.toml | 3 +- uv.lock | 85 +++++++++++++++++++++++++------------------------- 2 files changed, 43 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6881818f..842a72be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" addopts = "--dist loadscope" markers = [ - "dev: marks tests that are currently developed", - "integration: marks integration tests that actually start Balatro (deselect with '-m \"not integration\"')", + "dev: marks tests currently being developed (run with `-m dev`, remove when done)", ] [tool.commitizen] diff --git a/uv.lock b/uv.lock index 63c9f727..b6c9e09a 100644 --- a/uv.lock +++ b/uv.lock @@ -843,28 +843,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -926,27 +925,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" }, - { url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" }, - { url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" }, - { url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" }, - { url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" }, - { url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" }, - { url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" }, - { url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" }, - { url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" }, - { url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" }, - { url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" }, +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz", hash = "sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size = 5726484, upload-time = "2026-05-27T17:55:43.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl", hash = "sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size = 11406372, upload-time = "2026-05-27T17:55:49.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size = 11159782, upload-time = "2026-05-27T17:55:47.405Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size = 10594139, upload-time = "2026-05-27T17:55:37.206Z" }, + { url = "https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size = 11114156, upload-time = "2026-05-27T17:55:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size = 11189050, upload-time = "2026-05-27T17:55:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size = 11664266, upload-time = "2026-05-27T17:55:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size = 12187743, upload-time = "2026-05-27T17:56:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size = 11862999, upload-time = "2026-05-27T17:55:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size = 11743896, upload-time = "2026-05-27T17:56:00.017Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size = 11883581, upload-time = "2026-05-27T17:56:02.319Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size = 11093946, upload-time = "2026-05-27T17:55:33.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size = 11210737, upload-time = "2026-05-27T17:55:45.523Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size = 11332563, upload-time = "2026-05-27T17:55:41.674Z" }, + { url = "https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size = 11843216, upload-time = "2026-05-27T17:55:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl", hash = "sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size = 10670731, upload-time = "2026-05-27T17:55:31.316Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl", hash = "sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size = 11766211, upload-time = "2026-05-27T17:55:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" }, ] [[package]] From fad4f90b79d5d706661fff1fb136df60dd233f81 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:41:48 +0200 Subject: [PATCH 015/121] chore(tests): remove integration marker plumbing The integration marker is no longer used. Remove auto-marking hooks from conftest files and the @pytest.mark.integration decorator. --- tests/cli/conftest.py | 26 -------------------------- tests/cli/test_integration.py | 1 - tests/conftest.py | 5 ----- tests/lua/conftest.py | 10 +--------- 4 files changed, 1 insertion(+), 41 deletions(-) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 315dfd23..888705fd 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -3,7 +3,6 @@ import asyncio import os import random -from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest @@ -18,14 +17,6 @@ HOST = "127.0.0.1" -# Files that contain integration tests requiring Balatro -INTEGRATION_FILES = { - "test_client.py", - "test_api_cmd.py", - "test_serve_cmd.py", - "test_integration.py", -} - # ============================================================================ # Pytest Hooks for Balatro Instance Management @@ -88,23 +79,6 @@ async def stop_all(): print(f"Error stopping Balatro instances: {e}") -def pytest_collection_modifyitems(items): - """Mark integration test files automatically.""" - current_dir = Path(__file__).parent - - for item in items: - # Only process items in this directory - if ( - current_dir not in Path(item.path).parents - and Path(item.path).parent != current_dir - ): - continue - - # Mark files that need Balatro as integration tests - if item.path.name in INTEGRATION_FILES: - item.add_marker(pytest.mark.integration) - - # ============================================================================ # Session-scoped Fixtures for Integration Tests # ============================================================================ diff --git a/tests/cli/test_integration.py b/tests/cli/test_integration.py index 498941f9..a9d5a87d 100644 --- a/tests/cli/test_integration.py +++ b/tests/cli/test_integration.py @@ -13,7 +13,6 @@ def _random_port() -> int: return random.randint(20000, 30000) -@pytest.mark.integration class TestBalatroIntegration: """Integration tests that require a running Balatro instance.""" diff --git a/tests/conftest.py b/tests/conftest.py index 29d39f97..bbbf82ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1 @@ """Root test configuration.""" - - -def pytest_configure(config): - """Register custom markers.""" - config.addinivalue_line("markers", "integration: marks tests as integration tests") diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index b333933d..d841b7da 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -118,15 +118,7 @@ async def stop_all(): def pytest_collection_modifyitems(items): - """Mark all tests in this directory as integration tests.""" - from pathlib import Path - - current_dir = Path(__file__).parent - - for item in items: - # Check if the test file is within the current directory - if current_dir in Path(item.path).parents: - item.add_marker(pytest.mark.integration) + """No-op placeholder. Kept for pytest hook consistency.""" @pytest.fixture(scope="session") From 165506e5234f88c4065df40925e633aeeaee85a3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:41:53 +0200 Subject: [PATCH 016/121] chore(docs): exclude adr/ directory from mkdocs site --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 9a11e396..2f080698 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,8 @@ repo_name: "coder/balatrobot" repo_url: https://github.com/coder/balatrobot site_url: https://coder.github.io/balatrobot/ docs_dir: docs/ +exclude_docs: | + adr/ theme: name: material favicon: assets/balatrobot.svg From e16cc85f87ef9b9c5b2e3bf82c2170c056c14f77 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:41:59 +0200 Subject: [PATCH 017/121] fix(tests): use ty: ignore directive for type suppression --- tests/cli/test_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_manager.py b/tests/cli/test_manager.py index 84910d4d..04edac55 100644 --- a/tests/cli/test_manager.py +++ b/tests/cli/test_manager.py @@ -167,7 +167,7 @@ async def mock_start(config, session_dir): instance = BalatroInstance(logs_path=str(tmp_path)) # Mock health check to succeed immediately - instance._wait_for_health = AsyncMock() # type: ignore[assignment] + instance._wait_for_health = AsyncMock() # ty: ignore[invalid-assignment] async with instance: assert instance._process is mock_process From 6373afaaa5c7a47bf56a37f7c9d563014f64f317 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 27 May 2026 20:51:56 +0200 Subject: [PATCH 018/121] ci: align mdformat flags with Makefile --- .github/workflows/code_quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index fe399531..b6332fdd 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -48,7 +48,7 @@ jobs: - name: Check markdown formatting run: | source .venv/bin/activate - mdformat --check . + mdformat --check --number --exclude "CHANGELOG.md" --exclude ".venv/**" --exclude "vendors/**" ./docs README.md .agents/skills/balatrobot/SKILL.md - name: Run ty run: | source .venv/bin/activate From c1ee25829f0a908712216841c24fdc2e093feef6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:31:34 +0200 Subject: [PATCH 019/121] refactor(cli): rename manager.py to instance.py Rename BalatroInstance module to match its primary export. Update import paths in tests. --- src/balatrobot/{manager.py => instance.py} | 0 tests/cli/{test_manager.py => test_instance.py} | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/balatrobot/{manager.py => instance.py} (100%) rename tests/cli/{test_manager.py => test_instance.py} (97%) diff --git a/src/balatrobot/manager.py b/src/balatrobot/instance.py similarity index 100% rename from src/balatrobot/manager.py rename to src/balatrobot/instance.py diff --git a/tests/cli/test_manager.py b/tests/cli/test_instance.py similarity index 97% rename from tests/cli/test_manager.py rename to tests/cli/test_instance.py index 04edac55..23c9948d 100644 --- a/tests/cli/test_manager.py +++ b/tests/cli/test_instance.py @@ -6,7 +6,7 @@ import pytest from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance class TestBalatroInstanceInit: @@ -162,7 +162,7 @@ async def mock_start(config, session_dir): mock_launcher.build_env = MagicMock(return_value={}) mock_launcher.build_cmd = MagicMock(return_value=["echo"]) - monkeypatch.setattr("balatrobot.manager.get_launcher", lambda x: mock_launcher) + monkeypatch.setattr("balatrobot.instance.get_launcher", lambda x: mock_launcher) instance = BalatroInstance(logs_path=str(tmp_path)) From 631bc6045c9a50e4dea1224d298f196871eb1ad9 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:31:46 +0200 Subject: [PATCH 020/121] feat(pool): add BalatroPool for managing N BalatroInstance instances Introduce BalatroPool with start/stop lifecycle, automatic port allocation, fail-fast cleanup, and async context-manager support. Includes InstanceInfo frozen dataclass for connection metadata. --- src/balatrobot/pool.py | 128 ++++++++++++++++ tests/cli/test_pool.py | 328 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 src/balatrobot/pool.py create mode 100644 tests/cli/test_pool.py diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py new file mode 100644 index 00000000..b05ac36b --- /dev/null +++ b/src/balatrobot/pool.py @@ -0,0 +1,128 @@ +"""BalatroPool — manages N BalatroInstance instances.""" + +import asyncio +import uuid +from dataclasses import dataclass + +from balatrobot.config import Config +from balatrobot.instance import BalatroInstance + + +@dataclass(frozen=True) +class InstanceInfo: + """Immutable connection info for a running Balatro instance.""" + + host: str + port: int + + @property + def url(self) -> str: + """Full HTTP URL for this instance.""" + return f"http://{self.host}:{self.port}" + + +class BalatroPool: + """Manages N BalatroInstance instances with port allocation. + + The pool creates ``n`` instances from a base config, assigning unique + ports to each. It supports ``start()``/``stop()`` as well as the + async context-manager protocol. + + Fail-fast: if any instance fails to start, all already-started + instances are stopped and the error is re-raised. + """ + + def __init__( + self, + config: Config, + n: int = 1, + ports: list[int] | None = None, + ) -> None: + self._config = config + self._ports = ports + if ports is not None: + self._n = len(ports) + else: + self._n = n + self._instances: list[BalatroInstance] = [] + self._infos: list[InstanceInfo] = [] + self._started = False + self._session_id: str | None = None + + @property + def n(self) -> int: + """Number of instances in the pool.""" + return self._n + + @property + def is_started(self) -> bool: + """Whether the pool has been started.""" + return self._started + + @property + def instances(self) -> list[InstanceInfo]: + """List of InstanceInfo for started instances.""" + return list(self._infos) + + async def start(self) -> None: + """Allocate ports, spawn instances, health-check, clean up on failure.""" + if self._started: + raise RuntimeError("Pool already started") + + # Allocate ports (lazy import to avoid circular dependency) + if self._ports is not None: + ports = self._ports + else: + from balatrobot.state import allocate_ports + ports = allocate_ports(self._n) + + # Generate shared session ID + self._session_id = uuid.uuid4().hex[:12] + + # Create and start instances + self._instances = [] + self._infos = [] + + try: + for port in ports: + inst = BalatroInstance( + self._config, + session_id=self._session_id, + port=port, + ) + await inst.start() + self._instances.append(inst) + self._infos.append( + InstanceInfo(host=self._config.host, port=port) + ) + except Exception: + # Fail-fast: stop all instances that were started + await self._stop_all() + raise + + self._started = True + + async def stop(self) -> None: + """Stop all instances concurrently.""" + if not self._started: + return + await self._stop_all() + + async def _stop_all(self) -> None: + """Internal: stop all instances concurrently.""" + if not self._instances: + return + await asyncio.gather( + *(inst.stop() for inst in self._instances), + return_exceptions=True, + ) + self._instances = [] + self._infos = [] + self._started = False + + async def __aenter__(self) -> "BalatroPool": + await self.start() + return self + + async def __aexit__(self, *args) -> None: + await self.stop() diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py new file mode 100644 index 00000000..212d7410 --- /dev/null +++ b/tests/cli/test_pool.py @@ -0,0 +1,328 @@ +"""Tests for balatrobot.pool module.""" + +import asyncio +from dataclasses import FrozenInstanceError +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from balatrobot.config import Config +from balatrobot.instance import BalatroInstance +from balatrobot.pool import BalatroPool, InstanceInfo + + +# ============================================================================ +# InstanceInfo tests +# ============================================================================ + + +class TestInstanceInfo: + """Tests for InstanceInfo frozen dataclass.""" + + def test_create_with_host_port(self): + """InstanceInfo stores host and port.""" + info = InstanceInfo(host="127.0.0.1", port=12346) + assert info.host == "127.0.0.1" + assert info.port == 12346 + + def test_url_property(self): + """url property returns formatted URL.""" + info = InstanceInfo(host="0.0.0.0", port=9999) + assert info.url == "http://0.0.0.0:9999" + + def test_frozen(self): + """InstanceInfo is immutable.""" + info = InstanceInfo(host="127.0.0.1", port=12346) + with pytest.raises(FrozenInstanceError): + info.port = 9999 # type: ignore[misc] + + def test_equality(self): + """Two InstanceInfo with same values are equal.""" + a = InstanceInfo(host="127.0.0.1", port=12346) + b = InstanceInfo(host="127.0.0.1", port=12346) + assert a == b + + def test_inequality(self): + """Different port means different InstanceInfo.""" + a = InstanceInfo(host="127.0.0.1", port=12346) + b = InstanceInfo(host="127.0.0.1", port=9999) + assert a != b + + +# ============================================================================ +# BalatroPool tests +# ============================================================================ + + +class TestBalatroPoolInit: + """Tests for BalatroPool initialization.""" + + def test_init_defaults(self): + """Pool defaults to n=1, no ports.""" + config = Config() + pool = BalatroPool(config) + assert pool.n == 1 + assert pool.is_started is False + assert pool.instances == [] + + def test_init_with_n(self): + """Pool accepts n parameter.""" + config = Config() + pool = BalatroPool(config, n=3) + assert pool.n == 3 + + def test_init_with_ports(self): + """Pool accepts explicit ports list.""" + config = Config() + pool = BalatroPool(config, ports=[10000, 10001, 10002]) + assert pool.n == 3 + + def test_init_ports_override_n(self): + """Ports length overrides n.""" + config = Config() + pool = BalatroPool(config, n=5, ports=[10000, 10001]) + assert pool.n == 2 + + +class TestBalatroPoolStartStop: + """Tests for BalatroPool start/stop lifecycle.""" + + @pytest.mark.asyncio + async def test_start_creates_instances(self, tmp_path, monkeypatch): + """start() creates n instances and populates instances list.""" + config = Config(logs_path=str(tmp_path)) + + # Mock BalatroInstance + mock_inst = AsyncMock(spec=BalatroInstance) + mock_inst.port = 12346 + mock_inst._config = config + mock_inst.start = AsyncMock() + + created_instances = [] + + def mock_instance_factory(config_arg, **kwargs): + inst = MagicMock(spec=BalatroInstance) + port = kwargs.get("port", 12346) + inst.port = port + inst.start = AsyncMock() + inst.stop = AsyncMock() + created_instances.append(inst) + return inst + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + assert pool.is_started is True + assert len(pool.instances) == 2 + assert pool.instances[0].port == 14001 + assert pool.instances[1].port == 14002 + + await pool.stop() + + @pytest.mark.asyncio + async def test_stop_concurrent(self, tmp_path): + """stop() stops all instances concurrently.""" + config = Config(logs_path=str(tmp_path)) + + mock_instances = [] + for port in [14001, 14002]: + inst = MagicMock(spec=BalatroInstance) + inst.port = port + inst.start = AsyncMock() + inst.stop = AsyncMock() + mock_instances.append(inst) + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instances): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + await pool.stop() + + assert pool.is_started is False + for inst in mock_instances: + inst.stop.assert_called_once() + + @pytest.mark.asyncio + async def test_start_fail_cleans_up(self, tmp_path): + """If one instance fails to start, all are stopped.""" + config = Config(logs_path=str(tmp_path)) + + started_inst = MagicMock(spec=BalatroInstance) + started_inst.port = 14001 + started_inst.start = AsyncMock() + started_inst.stop = AsyncMock() + + failed_inst = MagicMock(spec=BalatroInstance) + failed_inst.port = 14002 + failed_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) + failed_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", side_effect=[started_inst, failed_inst]): + pool = BalatroPool(config, ports=[14001, 14002]) + with pytest.raises(RuntimeError, match="start failed"): + await pool.start() + + # Started instance should have been stopped + started_inst.stop.assert_called_once() + assert pool.is_started is False + assert pool.instances == [] + + @pytest.mark.asyncio + async def test_stop_idempotent(self, tmp_path): + """stop() is safe to call when not started.""" + config = Config(logs_path=str(tmp_path)) + pool = BalatroPool(config) + await pool.stop() # Should not raise + + @pytest.mark.asyncio + async def test_start_already_started(self, tmp_path): + """start() raises if already started.""" + config = Config(logs_path=str(tmp_path)) + pool = BalatroPool(config) + + mock_inst = MagicMock(spec=BalatroInstance) + mock_inst.port = 14001 + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + await pool.start() + with pytest.raises(RuntimeError, match="already started"): + await pool.start() + await pool.stop() + + @pytest.mark.asyncio + async def test_instances_populated_after_start(self, tmp_path): + """instances returns InstanceInfo list after start.""" + config = Config(logs_path=str(tmp_path)) + + mock_instances = [] + for port in [14001, 14002]: + inst = MagicMock(spec=BalatroInstance) + inst.port = port + inst.start = AsyncMock() + inst.stop = AsyncMock() + mock_instances.append(inst) + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instances): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + infos = pool.instances + assert len(infos) == 2 + assert all(isinstance(i, InstanceInfo) for i in infos) + assert infos[0].port == 14001 + assert infos[1].port == 14002 + assert infos[0].host == config.host + + await pool.stop() + + +class TestBalatroPoolContextManager: + """Tests for BalatroPool async context manager.""" + + @pytest.mark.asyncio + async def test_context_manager(self, tmp_path): + """Pool works as async context manager.""" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock(spec=BalatroInstance) + mock_inst.port = 14001 + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + async with BalatroPool(config, ports=[14001]) as pool: + assert pool.is_started is True + assert len(pool.instances) == 1 + + assert pool.is_started is False + + +class TestBalatroPoolPortAllocation: + """Tests for automatic port allocation.""" + + @pytest.mark.asyncio + async def test_auto_allocate_ports(self, tmp_path): + """Pool allocates ports automatically when none specified.""" + config = Config(logs_path=str(tmp_path)) + pool = BalatroPool(config, n=2) + + captured_ports = [] + + def mock_instance_factory(config_arg, **kwargs): + port = kwargs.get("port") + captured_ports.append(port) + inst = MagicMock(spec=BalatroInstance) + inst.port = port + inst.start = AsyncMock() + inst.stop = AsyncMock() + return inst + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + await pool.start() + + assert len(captured_ports) == 2 + assert captured_ports[0] != captured_ports[1] + assert all(isinstance(p, int) for p in captured_ports) + + await pool.stop() + + +class TestBalatroPoolConfigDerivation: + """Tests for pool config derivation.""" + + @pytest.mark.asyncio + async def test_derives_config_per_instance(self, tmp_path): + """Pool derives configs from base config, each with unique port.""" + config = Config(host="0.0.0.0", logs_path=str(tmp_path)) + + captured_configs = [] + captured_overrides = [] + + def mock_instance_factory(config_arg, **kwargs): + captured_configs.append(config_arg) + captured_overrides.append(kwargs) + inst = MagicMock(spec=BalatroInstance) + inst.port = kwargs.get("port", 12346) + inst.start = AsyncMock() + inst.stop = AsyncMock() + return inst + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + # All configs should share host/logs_path + assert all(c.host == "0.0.0.0" for c in captured_configs) + # Each gets a different port + assert captured_overrides[0]["port"] == 14001 + assert captured_overrides[1]["port"] == 14002 + + await pool.stop() + + @pytest.mark.asyncio + async def test_shared_session_id(self, tmp_path): + """Pool generates a shared session ID for all instances.""" + config = Config(logs_path=str(tmp_path)) + + captured_session_ids = [] + + def mock_instance_factory(config_arg, **kwargs): + session_id = kwargs.get("session_id") + captured_session_ids.append(session_id) + inst = MagicMock(spec=BalatroInstance) + inst.port = kwargs.get("port", 12346) + inst.start = AsyncMock() + inst.stop = AsyncMock() + return inst + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + # All instances share the same session ID + assert len(set(captured_session_ids)) == 1 + assert captured_session_ids[0] is not None + + await pool.stop() From 570c69a5407b6b4edb7b7cb9769b966fe5f881d3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:31:58 +0200 Subject: [PATCH 021/121] feat(state): add StateFile for filesystem-based instance discovery StateFile wraps BalatroPool with a JSON state file (Jupyter pattern). Atomic write on pool start, delete on stop. Supports PID-based liveness checks, stale-file cleanup, and resolve-by-host:port or index. Add platformdirs dependency for cross-platform state directory. --- pyproject.toml | 2 +- src/balatrobot/state.py | 290 +++++++++++++++++++++++++++++ tests/cli/test_state.py | 396 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 + 4 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 src/balatrobot/state.py create mode 100644 tests/cli/test_state.py diff --git a/pyproject.toml b/pyproject.toml index 842a72be..e083e628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ { name = "phughesion" }, ] requires-python = ">=3.13" -dependencies = ["httpx>=0.28.1", "typer>=0.15"] +dependencies = ["httpx>=0.28.1", "platformdirs>=4.0", "typer>=0.15"] classifiers = [ "Framework :: Pytest", "Intended Audience :: Developers", diff --git a/src/balatrobot/state.py b/src/balatrobot/state.py new file mode 100644 index 00000000..92fac207 --- /dev/null +++ b/src/balatrobot/state.py @@ -0,0 +1,290 @@ +"""StateFile — filesystem-based discovery for running BalatroPool instances. + +StateFile wraps a BalatroPool with a JSON state file (Jupyter pattern). +It writes connection info atomically on pool start and deletes it on pool +stop, enabling discovery by CLI tools and test fixtures. +""" + +import json +import os +import socket +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from platformdirs import user_state_dir + +from balatrobot.pool import BalatroPool, InstanceInfo + + +# --------------------------------------------------------------------------- +# Port allocation +# --------------------------------------------------------------------------- + + +def _allocate_port() -> int: + """Allocate a single free port via bind(0).""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def allocate_ports(n: int) -> list[int]: + """Allocate n free ports. + + Uses bind(0) to find available ports. There is a small TOCTOU window + between allocation and actual use, but this is acceptable for the + pool use case. + """ + return [_allocate_port() for _ in range(n)] + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class StateFileError(Exception): + """Base exception for state file operations.""" + + +class StateFileBusy(StateFileError): + """A live state file already exists (another pool is running).""" + + def __init__(self, path: str | Path, pid: int) -> None: + self.path = str(path) + self.pid = pid + super().__init__( + f"State file {path!s} is locked by PID {pid}" + ) + + +class StateFileNotFound(StateFileError): + """No state file found.""" + + def __init__(self, path: str | Path | None = None) -> None: + self.path = str(path) if path is not None else None + super().__init__(f"No state file found at {path!s}" if path else "No state file found") + + +class InstanceNotFoundError(StateFileError): + """Requested instance index or host:port not in state file.""" + + def __init__(self, index: int | None = None, total: int | None = None) -> None: + self.index = index + self.total = total + msg_parts = [] + if index is not None: + msg_parts.append(f"index={index}") + if total is not None: + msg_parts.append(f"total={total}") + super().__init__(f"Instance not found ({', '.join(msg_parts)})") + + +# --------------------------------------------------------------------------- +# StateFile +# --------------------------------------------------------------------------- + +_DEFAULT_FILENAME = "state.json" +_ENV_STATE_DIR = "BALATROBOT_STATE_DIR" + + +def _default_state_path() -> Path: + """Resolve the default state file path. + + Uses ``BALATROBOT_STATE_DIR`` env var if set, otherwise falls back + to ``platformdirs.user_state_dir("balatrobot")``. + """ + env_dir = os.environ.get(_ENV_STATE_DIR) + if env_dir: + base = Path(env_dir) + else: + base = Path(user_state_dir("balatrobot")) + return base / _DEFAULT_FILENAME + + +def _is_pid_alive(pid: int) -> bool: + """Check whether *pid* is a running process.""" + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError, OSError): + return False + + +class StateFile: + """Wraps a :class:`BalatroPool` with filesystem-based discovery. + + Usage:: + + async with StateFile(BalatroPool(config, n=2)) as sf: + # state file written; other processes can discover instances + print(sf.instances) + # state file deleted on exit + """ + + def __init__( + self, + pool: BalatroPool, + path: Path | None = None, + ) -> None: + self._pool = pool + self._path = path or _default_state_path() + + @property + def path(self) -> Path: + """Resolved state file path.""" + return self._path + + @property + def instances(self) -> list[InstanceInfo]: + """Delegates to pool.instances.""" + return self._pool.instances + + @property + def is_started(self) -> bool: + """Delegates to pool.is_started.""" + return self._pool.is_started + + # -- Static helpers ----------------------------------------------------- + + @staticmethod + def read(path: Path | None = None) -> dict[str, Any] | None: + """Read and validate a state file. + + Returns ``None`` if the file doesn't exist, contains invalid JSON, + or references a dead PID (in which case the orphan file is deleted). + + Args: + path: Path to read. Defaults to the platform-default path. + """ + state_path = path or _default_state_path() + + if not state_path.exists(): + return None + + try: + data = json.loads(state_path.read_text()) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + + pid = data.get("pid") + if pid is not None and not _is_pid_alive(pid): + # Orphan — auto-delete + try: + state_path.unlink() + except OSError: + pass + return None + + return data + + @staticmethod + def resolve( + host: str | None = None, + port: int | None = None, + index: int | None = None, + path: Path | None = None, + ) -> InstanceInfo: + """Discover an instance from the state file. + + Resolution order: + 1. If both *host* and *port* are given, find matching instance. + 2. If *index* is given (or defaults to 0), return that instance. + 3. Raises on missing state file, empty instances, or not found. + + Args: + host: Filter by host. + port: Filter by port. + index: Instance index (0-based). Defaults to 0. + path: State file path override. + + Raises: + StateFileNotFound: No state file or empty instances. + InstanceNotFoundError: No matching instance. + """ + data = StateFile.read(path) + if data is None: + raise StateFileNotFound(path or _default_state_path()) + + instances = data.get("instances", []) + if not instances: + raise StateFileNotFound(path or _default_state_path()) + + # Explicit host+port lookup + if host is not None and port is not None: + for inst in instances: + if inst["host"] == host and inst["port"] == port: + return InstanceInfo(host=inst["host"], port=inst["port"]) + raise InstanceNotFoundError(index=None, total=len(instances)) + + # Index-based lookup (default to 0) + idx = index if index is not None else 0 + if idx < 0 or idx >= len(instances): + raise InstanceNotFoundError(index=idx, total=len(instances)) + + inst = instances[idx] + return InstanceInfo(host=inst["host"], port=inst["port"]) + + # -- Context manager ---------------------------------------------------- + + async def __aenter__(self) -> "StateFile": + """Check for existing live PID, start pool, write state file.""" + # Check for existing live state file + existing = StateFile.read(self._path) + if existing is not None: + raise StateFileBusy(path=self._path, pid=existing["pid"]) + + # Start the pool + await self._pool.start() + + # Write state file atomically + self._write_state() + + return self + + async def __aexit__(self, *args: object) -> None: + """Delete state file and stop pool.""" + self._delete_state() + await self._pool.stop() + + # -- Internal ----------------------------------------------------------- + + def _write_state(self) -> None: + """Write state file atomically.""" + data = { + "pid": os.getpid(), + "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "instances": [ + {"host": info.host, "port": info.port} + for info in self._pool.instances + ], + } + # Ensure parent directory exists + self._path.parent.mkdir(parents=True, exist_ok=True) + + # Atomic write: write to temp file, then rename + fd, tmp_path = tempfile.mkstemp( + dir=str(self._path.parent), + prefix=".state-", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f) + os.replace(tmp_path, str(self._path)) + except BaseException: + # Clean up temp file on error + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + def _delete_state(self) -> None: + """Delete state file.""" + try: + self._path.unlink(missing_ok=True) + except OSError: + pass diff --git a/tests/cli/test_state.py b/tests/cli/test_state.py new file mode 100644 index 00000000..90a408d4 --- /dev/null +++ b/tests/cli/test_state.py @@ -0,0 +1,396 @@ +"""Tests for balatrobot.state module.""" + +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from balatrobot.config import Config +from balatrobot.pool import BalatroPool, InstanceInfo +from balatrobot.state import ( + InstanceNotFoundError, + StateFile, + StateFileBusy, + StateFileError, + StateFileNotFound, + allocate_ports, +) + + +# ============================================================================ +# allocate_ports tests +# ============================================================================ + + +class TestAllocatePorts: + """Tests for allocate_ports helper.""" + + def test_allocate_one_port(self): + """Allocates one port.""" + ports = allocate_ports(1) + assert len(ports) == 1 + assert isinstance(ports[0], int) + + def test_allocate_multiple_ports(self): + """Allocates multiple distinct ports.""" + ports = allocate_ports(3) + assert len(ports) == 3 + assert len(set(ports)) == 3 # All unique + + def test_allocate_zero_ports(self): + """Allocates zero ports.""" + ports = allocate_ports(0) + assert ports == [] + + def test_ports_in_valid_range(self): + """Allocated ports are in valid ephemeral range.""" + ports = allocate_ports(5) + for port in ports: + assert 1024 <= port <= 65535 + + +# ============================================================================ +# Exception hierarchy tests +# ============================================================================ + + +class TestExceptions: + """Tests for state exception hierarchy.""" + + def test_state_file_error_base(self): + """StateFileError is the base exception.""" + err = StateFileError("test") + assert isinstance(err, Exception) + assert str(err) == "test" + + def test_state_file_busy(self): + """StateFileBusy is a StateFileError.""" + err = StateFileBusy(path="/tmp/state.json", pid=1234) + assert isinstance(err, StateFileError) + assert err.path == "/tmp/state.json" + assert err.pid == 1234 + + def test_state_file_not_found(self): + """StateFileNotFound is a StateFileError.""" + err = StateFileNotFound(path="/tmp/state.json") + assert isinstance(err, StateFileError) + assert err.path == "/tmp/state.json" + + def test_instance_not_found_error(self): + """InstanceNotFoundError is a StateFileError.""" + err = InstanceNotFoundError(index=5, total=3) + assert isinstance(err, StateFileError) + assert err.index == 5 + assert err.total == 3 + + +# ============================================================================ +# StateFile.read tests +# ============================================================================ + + +class TestStateFileRead: + """Tests for StateFile.read static method.""" + + def test_read_valid_state(self, tmp_path): + """Reads a valid state file.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + {"host": "127.0.0.1", "port": 14001}, + {"host": "127.0.0.1", "port": 14002}, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = StateFile.read(state_path) + assert result is not None + assert result["pid"] == os.getpid() + assert len(result["instances"]) == 2 + + def test_read_missing_file(self, tmp_path): + """Returns None for missing file.""" + result = StateFile.read(tmp_path / "nonexistent.json") + assert result is None + + def test_read_stale_state_auto_deletes(self, tmp_path): + """Auto-deletes state file if PID is no longer alive.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": 999999999, # Non-existent PID + "started_at": "2026-05-28T12:00:00Z", + "instances": [{"host": "127.0.0.1", "port": 14001}], + } + state_path.write_text(json.dumps(state_data)) + + result = StateFile.read(state_path) + assert result is None + assert not state_path.exists() + + def test_read_invalid_json(self, tmp_path): + """Returns None for invalid JSON.""" + state_path = tmp_path / "state.json" + state_path.write_text("not json") + + result = StateFile.read(state_path) + assert result is None + + def test_read_default_path(self, tmp_path, monkeypatch): + """Reads from default path when no path given.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = StateFile.read() + assert result is None # No file exists yet + + +# ============================================================================ +# StateFile.resolve tests +# ============================================================================ + + +class TestStateFileResolve: + """Tests for StateFile.resolve static method.""" + + def test_resolve_by_host_port(self, tmp_path, monkeypatch): + """Resolves by explicit host and port.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + {"host": "127.0.0.1", "port": 14001}, + {"host": "127.0.0.1", "port": 14002}, + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + info = StateFile.resolve(host="127.0.0.1", port=14002) + assert info.port == 14002 + + def test_resolve_by_index(self, tmp_path, monkeypatch): + """Resolves by index.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + {"host": "127.0.0.1", "port": 14001}, + {"host": "127.0.0.1", "port": 14002}, + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + info = StateFile.resolve(index=1) + assert info.port == 14002 + + def test_resolve_no_state_file(self, tmp_path, monkeypatch): + """Raises StateFileNotFound when no state file.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + with pytest.raises(StateFileNotFound): + StateFile.resolve() + + def test_resolve_empty_instances(self, tmp_path, monkeypatch): + """Raises StateFileNotFound when instances list is empty.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + with pytest.raises(StateFileNotFound): + StateFile.resolve() + + def test_resolve_index_out_of_range(self, tmp_path, monkeypatch): + """Raises InstanceNotFoundError for invalid index.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [{"host": "127.0.0.1", "port": 14001}], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + with pytest.raises(InstanceNotFoundError) as exc_info: + StateFile.resolve(index=5) + assert exc_info.value.index == 5 + assert exc_info.value.total == 1 + + def test_resolve_default_index_zero(self, tmp_path, monkeypatch): + """Default index is 0.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + {"host": "127.0.0.1", "port": 14001}, + {"host": "127.0.0.1", "port": 14002}, + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + info = StateFile.resolve() + assert info.port == 14001 # index=0 by default + + def test_resolve_host_port_not_in_instances(self, tmp_path, monkeypatch): + """Raises InstanceNotFoundError when host:port not found in instances.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [{"host": "127.0.0.1", "port": 14001}], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + with pytest.raises(InstanceNotFoundError): + StateFile.resolve(host="127.0.0.1", port=99999) + + +# ============================================================================ +# StateFile context manager tests +# ============================================================================ + + +class TestStateFileContextManager: + """Tests for StateFile as async context manager.""" + + @pytest.mark.asyncio + async def test_context_manager_writes_state(self, tmp_path): + """StateFile writes state file on enter, deletes on exit.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + pool = BalatroPool(config, ports=[14001]) + sf = StateFile(pool, path=state_path) + async with sf: + assert state_path.exists() + data = json.loads(state_path.read_text()) + assert data["pid"] == os.getpid() + assert len(data["instances"]) == 1 + assert data["instances"][0]["port"] == 14001 + assert "started_at" in data + + assert not state_path.exists() + + @pytest.mark.asyncio + async def test_delegates_instances(self, tmp_path): + """StateFile.instances delegates to pool.""" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + pool = BalatroPool(config, ports=[14001]) + sf = StateFile(pool, path=tmp_path / "state.json") + async with sf: + assert len(sf.instances) == 1 + assert sf.instances[0].port == 14001 + + @pytest.mark.asyncio + async def test_path_property(self, tmp_path): + """StateFile.path returns resolved path.""" + state_path = tmp_path / "state.json" + config = Config() + pool = BalatroPool(config) + sf = StateFile(pool, path=state_path) + assert sf.path == state_path + + @pytest.mark.asyncio + async def test_double_start_raises_busy(self, tmp_path): + """StateFileBusy raised if another live state file exists.""" + state_path = tmp_path / "state.json" + + # Write a "live" state file with current PID + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [{"host": "127.0.0.1", "port": 14001}], + } + state_path.write_text(json.dumps(state_data)) + + config = Config(logs_path=str(tmp_path)) + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + pool = BalatroPool(config, ports=[14001]) + sf = StateFile(pool, path=state_path) + with pytest.raises(StateFileBusy): + async with sf: + pass + + @pytest.mark.asyncio + async def test_stale_state_does_not_raise_busy(self, tmp_path): + """Stale state file is cleaned up and doesn't raise StateFileBusy.""" + state_path = tmp_path / "state.json" + + # Write a "stale" state file with dead PID + state_data = { + "pid": 999999999, + "started_at": "2026-05-28T12:00:00Z", + "instances": [{"host": "127.0.0.1", "port": 14001}], + } + state_path.write_text(json.dumps(state_data)) + + config = Config(logs_path=str(tmp_path)) + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + pool = BalatroPool(config, ports=[14001]) + sf = StateFile(pool, path=state_path) + async with sf: + # Should succeed — stale file cleaned up + assert sf.is_started is True + + +# ============================================================================ +# StateFile path resolution tests +# ============================================================================ + + +class TestStateFilePath: + """Tests for StateFile default path resolution.""" + + def test_default_path_uses_platformdirs(self, tmp_path, monkeypatch): + """Default path uses platformdirs.user_state_dir.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + config = Config() + pool = BalatroPool(config) + sf = StateFile(pool, path=tmp_path / "state.json") + assert sf.path == tmp_path / "state.json" + + def test_env_var_overrides_path(self, tmp_path, monkeypatch): + """BALATROBOT_STATE_DIR overrides default path.""" + state_dir = tmp_path / "custom_state" + state_dir.mkdir() + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(state_dir)) + + config = Config() + pool = BalatroPool(config) + sf = StateFile(pool) + assert sf.path == state_dir / "state.json" diff --git a/uv.lock b/uv.lock index b6c9e09a..24a1e372 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,7 @@ version = "1.5.2" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "platformdirs" }, { name = "typer" }, ] @@ -82,6 +83,7 @@ test = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, + { name = "platformdirs", specifier = ">=4.0" }, { name = "typer", specifier = ">=0.15" }, ] From 5b3698a1f15f53ec608b7993d2fd4a2f5378d9bc Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:32:13 +0200 Subject: [PATCH 022/121] feat(serve): use BalatroPool and StateFile in serve command Replace single BalatroInstance with pool-based serve. Adds -n / --num-instances flag for launching multiple instances. State file is written on start and cleaned up on exit. --- src/balatrobot/cli/serve.py | 22 +++++++++++++++------- tests/cli/test_serve_cmd.py | 2 ++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 9c2af0cf..6ef16cb0 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -1,4 +1,4 @@ -"""Serve command - Start Balatro with BalatroBot mod loaded.""" +"""Serve command — start Balatro with BalatroBot mod loaded.""" import asyncio from typing import Annotated @@ -6,7 +6,8 @@ import typer from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.pool import BalatroPool +from balatrobot.state import StateFile # Platform choices for validation PLATFORM_CHOICES = ["darwin", "linux", "windows", "native"] @@ -20,6 +21,9 @@ def serve( port: Annotated[ int | None, typer.Option(help="Server port (default: 12346)") ] = None, + num_instances: Annotated[ + int, typer.Option("-n", "--num-instances", help="Number of instances to start (default: 1)") + ] = 1, fps_cap: Annotated[ int | None, typer.Option(help="Maximum FPS cap (default: 60)") ] = None, @@ -95,14 +99,18 @@ def serve( ) try: - asyncio.run(_serve(config)) + asyncio.run(_serve(config, num_instances)) except KeyboardInterrupt: typer.echo("\nShutting down server...") -async def _serve(config: Config) -> None: - """Async serve implementation.""" - async with BalatroInstance(config) as instance: - typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") +async def _serve(config: Config, n: int) -> None: + """Async serve implementation using StateFile + BalatroPool.""" + pool = BalatroPool(config, n=n) + async with StateFile(pool) as sf: + instances = sf.instances + for i, info in enumerate(instances): + typer.echo(f"Instance [{i}]: {info.url}") + typer.echo(f"PID: {sf._pool._session_id}. Press Ctrl+C to stop.") while True: await asyncio.sleep(5) diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index 95bf4aae..bea014fd 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -35,6 +35,7 @@ def test_serve_help(self): assert "--fast" in result.output assert "--headless" in result.output assert "--platform" in result.output + assert "--num-instances" in result.output or "-n" in result.output # --- Config.from_kwargs tests --- @@ -78,6 +79,7 @@ def test_main_help(self): assert result.exit_code == 0 assert "serve" in result.output assert "api" in result.output + assert "list" in result.output def test_no_args_shows_help(self): """Running without args shows help (exit code 2 for multi-command apps).""" From 97e6b73d678b4af1a510b684d41fda8398aed351 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:32:26 +0200 Subject: [PATCH 023/121] feat(cli): add list command to show running instances New `balatrobot list` command reads the state file and displays running instances. Supports --json for machine-readable output. Register in CLI app. --- src/balatrobot/cli/__init__.py | 2 + src/balatrobot/cli/list.py | 35 +++++++++++++++++ tests/cli/test_list_cmd.py | 72 ++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/balatrobot/cli/list.py create mode 100644 tests/cli/test_list_cmd.py diff --git a/src/balatrobot/cli/__init__.py b/src/balatrobot/cli/__init__.py index 974828e7..6998ccad 100644 --- a/src/balatrobot/cli/__init__.py +++ b/src/balatrobot/cli/__init__.py @@ -3,6 +3,7 @@ import typer from balatrobot.cli.api import api +from balatrobot.cli.list import list_cmd from balatrobot.cli.serve import serve app = typer.Typer( @@ -14,6 +15,7 @@ # Register commands app.command()(serve) app.command()(api) +app.command(name="list")(list_cmd) def main() -> None: diff --git a/src/balatrobot/cli/list.py b/src/balatrobot/cli/list.py new file mode 100644 index 00000000..25fd8244 --- /dev/null +++ b/src/balatrobot/cli/list.py @@ -0,0 +1,35 @@ +"""List command — show running BalatroBot instances.""" + +import json +from typing import Annotated + +import typer + +from balatrobot.state import StateFile + + +def list_cmd( + json_output: Annotated[ + bool, typer.Option("--json", help="Output as JSON") + ] = False, +) -> None: + """List running BalatroBot instances.""" + data = StateFile.read() + + if data is None or not data.get("instances"): + if json_output: + typer.echo(json.dumps({"instances": []})) + else: + typer.echo("No running instances.") + return + + if json_output: + typer.echo(json.dumps(data, indent=2)) + return + + instances = data["instances"] + typer.echo(f"PID: {data['pid']}") + typer.echo(f"Started: {data['started_at']}") + typer.echo(f"Instances ({len(instances)}):") + for i, inst in enumerate(instances): + typer.echo(f" [{i}] http://{inst['host']}:{inst['port']}") diff --git a/tests/cli/test_list_cmd.py b/tests/cli/test_list_cmd.py new file mode 100644 index 00000000..e8f949b3 --- /dev/null +++ b/tests/cli/test_list_cmd.py @@ -0,0 +1,72 @@ +"""Tests for balatrobot cli list command.""" + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from balatrobot.cli import app +from balatrobot.pool import InstanceInfo +from balatrobot.state import StateFile + +runner = CliRunner() + + +class TestListCommand: + """Test balatrobot list command.""" + + def test_list_no_state_file(self, tmp_path, monkeypatch): + """List shows message when no state file exists.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "No running instances" in result.output + + def test_list_with_instances(self, tmp_path, monkeypatch): + """List shows running instances.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + # Write a valid state file + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + {"host": "127.0.0.1", "port": 14001}, + {"host": "127.0.0.1", "port": 14002}, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "14001" in result.output + assert "14002" in result.output + + def test_list_json_output(self, tmp_path, monkeypatch): + """List --json outputs structured JSON.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + {"host": "127.0.0.1", "port": 14001}, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["instances"]) == 1 + assert data["instances"][0]["port"] == 14001 + + def test_list_help(self): + """List --help shows options.""" + result = runner.invoke(app, ["list", "--help"]) + assert result.exit_code == 0 + assert "--json" in result.output From a83d7e92e1c39638c9353bb55935986938627808 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:32:40 +0200 Subject: [PATCH 024/121] feat(api): add instance discovery to api command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host/port are now optional — when omitted, the api command resolves the target from the state file. Supports --index flag to select an instance (default: 0). Falls back gracefully when no state file exists. --- src/balatrobot/cli/api.py | 23 ++++++++++++++++++++--- tests/cli/test_api_cmd.py | 24 ++++++++++++++++-------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/balatrobot/cli/api.py b/src/balatrobot/cli/api.py index 7e20b6ca..29190838 100644 --- a/src/balatrobot/cli/api.py +++ b/src/balatrobot/cli/api.py @@ -8,6 +8,7 @@ import typer from balatrobot.cli.client import APIError, BalatroClient +from balatrobot.state import StateFile class Method(StrEnum): @@ -39,8 +40,11 @@ class Method(StrEnum): def api( method: Annotated[Method, typer.Argument(help="API method to call")], params: Annotated[str, typer.Argument(help="JSON params object")] = "{}", - host: Annotated[str, typer.Option(help="Server hostname")] = "127.0.0.1", - port: Annotated[int, typer.Option(help="Server port")] = 12346, + host: Annotated[str | None, typer.Option(help="Server hostname")] = None, + port: Annotated[int | None, typer.Option(help="Server port")] = None, + index: Annotated[ + int | None, typer.Option("--index", "-i", help="Instance index (default: 0)") + ] = None, ) -> None: """Call API endpoint on a running BalatroBot server.""" # Validate JSON params @@ -50,8 +54,21 @@ def api( typer.echo(f"Error: Invalid JSON params - {e}", err=True) raise typer.Exit(code=1) + # Resolve instance: explicit host+port, or discover from state file + if host is not None and port is not None: + target_host = host + target_port = port + else: + try: + info = StateFile.resolve(host=host, port=port, index=index) + target_host = info.host + target_port = info.port + except Exception as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + # Make API call - client = BalatroClient(host=host, port=port) + client = BalatroClient(host=target_host, port=target_port) try: result = client.call(method.value, params_dict) typer.echo(json.dumps(result, indent=2)) diff --git a/tests/cli/test_api_cmd.py b/tests/cli/test_api_cmd.py index c5e363fc..6466159b 100644 --- a/tests/cli/test_api_cmd.py +++ b/tests/cli/test_api_cmd.py @@ -16,8 +16,8 @@ class TestApiCommand: # --- Happy path tests --- def test_api_health_success(self, cli_port: int): - """api health returns JSON result.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port)]) + """api health returns JSON result with explicit port.""" + result = runner.invoke(app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "ok" @@ -25,7 +25,7 @@ def test_api_health_success(self, cli_port: int): def test_api_gamestate_success(self, cli_port: int, balatro_client: BalatroClient): """api gamestate returns state.""" balatro_client.call("menu") # Reset state - result = runner.invoke(app, ["api", "gamestate", "--port", str(cli_port)]) + result = runner.invoke(app, ["api", "gamestate", "--port", str(cli_port), "--host", "127.0.0.1"]) assert result.exit_code == 0 data = json.loads(result.output) assert "state" in data @@ -34,7 +34,7 @@ def test_api_with_params(self, cli_port: int, balatro_client: BalatroClient): """api command passes JSON params correctly.""" balatro_client.call("menu") params = json.dumps({"deck": "RED", "stake": "WHITE"}) - result = runner.invoke(app, ["api", "start", params, "--port", str(cli_port)]) + result = runner.invoke(app, ["api", "start", params, "--port", str(cli_port), "--host", "127.0.0.1"]) assert result.exit_code == 0 # --- Method validation tests --- @@ -66,7 +66,7 @@ def test_api_invalid_json_params(self, cli_port: int): def test_api_empty_params_default(self, cli_port: int): """Empty params default to {}.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port)]) + result = runner.invoke(app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"]) assert result.exit_code == 0 # --- API error handling tests --- @@ -75,7 +75,7 @@ def test_api_error_formatted(self, cli_port: int, balatro_client: BalatroClient) """API errors formatted as 'Error: NAME - message'.""" balatro_client.call("menu") result = runner.invoke( - app, ["api", "play", '{"cards": [0]}', "--port", str(cli_port)] + app, ["api", "play", '{"cards": [0]}', "--port", str(cli_port), "--host", "127.0.0.1"] ) assert result.exit_code == 1 assert "Error: INVALID_STATE" in result.output @@ -84,7 +84,7 @@ def test_api_error_formatted(self, cli_port: int, balatro_client: BalatroClient) def test_api_connection_error(self): """Connection error formatted correctly.""" - result = runner.invoke(app, ["api", "health", "--port", "1"]) + result = runner.invoke(app, ["api", "health", "--port", "1", "--host", "127.0.0.1"]) assert result.exit_code == 1 assert "Connection failed" in result.output @@ -92,7 +92,15 @@ def test_api_connection_error(self): def test_api_output_is_indented_json(self, cli_port: int): """Output is pretty-printed JSON.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port)]) + result = runner.invoke(app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"]) assert result.exit_code == 0 # Check for indentation (2 spaces) or compact format assert ' "status"' in result.output or '"status": "ok"' in result.output + + # --- Discovery tests --- + + def test_api_no_state_file_error(self, tmp_path, monkeypatch): + """Discovery fails gracefully when no state file.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["api", "health"]) + assert result.exit_code == 1 From 2d499954362f8ed19f346df98d3464db3262ea2b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:32:53 +0200 Subject: [PATCH 025/121] refactor(tests): use InstanceInfo in Lua and CLI test fixtures Replace separate host/port fixtures with unified InstanceInfo. Update Lua conftest, server tests, and CLI conftest imports. --- tests/cli/conftest.py | 2 +- tests/lua/conftest.py | 38 +++++++++++++++++------------------ tests/lua/core/test_server.py | 12 ++++++----- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 888705fd..9e49441d 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -9,7 +9,7 @@ from balatrobot.cli.client import BalatroClient from balatrobot.config import ENV_MAP, Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance # ============================================================================ # Constants diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index d841b7da..e0136e8c 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -13,7 +13,9 @@ import pytest from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance +from balatrobot.pool import InstanceInfo +from balatrobot.state import StateFile # ============================================================================ # Constants @@ -122,53 +124,49 @@ def pytest_collection_modifyitems(items): @pytest.fixture(scope="session") -def host() -> str: - """Return the default Balatro server host.""" - return HOST - - -@pytest.fixture(scope="session") -def port(worker_id) -> int: - """Get assigned port for this worker from env var.""" +def instance(worker_id) -> InstanceInfo: + """Return InstanceInfo for this worker's assigned instance.""" ports_str = os.environ.get("BALATROBOT_PORTS", "12346") ports = [int(p) for p in ports_str.split(",")] if worker_id == "master": - return ports[0] + port = ports[0] + else: + worker_num = int(worker_id.replace("gw", "")) + port = ports[worker_num] - worker_num = int(worker_id.replace("gw", "")) - return ports[worker_num] + return InstanceInfo(host=HOST, port=port) @pytest.fixture(scope="session") -async def balatro_server(port: int, worker_id) -> AsyncGenerator[None, None]: +async def balatro_server(instance: InstanceInfo) -> AsyncGenerator[None, None]: """Wait for pre-started Balatro instance to be healthy.""" timeout = 10.0 elapsed = 0.0 while elapsed < timeout: - if _check_health(HOST, port): - print(f"[{worker_id}] Connected to Balatro on port {port}") + if _check_health(instance.host, instance.port): + print(f"[worker] Connected to Balatro on port {instance.port}") yield None return await asyncio.sleep(0.5) elapsed += 0.5 - pytest.fail(f"Balatro instance on port {port} not responding") + pytest.fail(f"Balatro instance on port {instance.port} not responding") @pytest.fixture -def client(host: str, port: int, balatro_server) -> Generator[httpx.Client, None, None]: +def client(instance: InstanceInfo, balatro_server) -> Generator[httpx.Client, None, None]: """Create an HTTP client connected to Balatro game instance. Args: - host: The hostname or IP address of the Balatro game server. - port: The port number the Balatro game server is listening on. + instance: The InstanceInfo for the assigned instance. + balatro_server: Ensures the server is healthy. Yields: An httpx.Client for communicating with the game. """ with httpx.Client( - base_url=f"http://{host}:{port}", + base_url=instance.url, timeout=httpx.Timeout(CONNECTION_TIMEOUT, read=REQUEST_TIMEOUT), ) as http_client: yield http_client diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index b3fb9f23..3eca1344 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -18,22 +18,24 @@ import httpx import pytest +from balatrobot.pool import InstanceInfo + class TestHTTPServerInit: """Tests for HTTP server initialization and port binding.""" - def test_server_binds_to_configured_port(self, port: int, balatro_server) -> None: + def test_server_binds_to_configured_port(self, instance: InstanceInfo, balatro_server) -> None: """Test that server is listening on the expected port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(2) - sock.connect(("127.0.0.1", port)) - assert sock.fileno() != -1, f"Should connect to port {port}" + sock.connect(("127.0.0.1", instance.port)) + assert sock.fileno() != -1, f"Should connect to port {instance.port}" - def test_port_is_exclusively_bound(self, port: int, balatro_server) -> None: + def test_port_is_exclusively_bound(self, instance: InstanceInfo, balatro_server) -> None: """Test that server exclusively binds the port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with pytest.raises(OSError) as exc_info: - sock.bind(("127.0.0.1", port)) + sock.bind(("127.0.0.1", instance.port)) assert exc_info.value.errno == errno.EADDRINUSE def test_server_responds_to_http(self, client: httpx.Client) -> None: From c9641099025371f23352b3b5641655ec8ba08761 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 28 May 2026 19:33:08 +0200 Subject: [PATCH 026/121] feat: export BalatroPool, InstanceInfo, and StateFile from public API Update __init__.py to re-export the new modules so users can import directly from the balatrobot package. --- src/balatrobot/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/balatrobot/__init__.py b/src/balatrobot/__init__.py index 970a360d..1a2654de 100644 --- a/src/balatrobot/__init__.py +++ b/src/balatrobot/__init__.py @@ -2,7 +2,18 @@ from balatrobot.cli.client import APIError, BalatroClient from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance +from balatrobot.pool import BalatroPool, InstanceInfo +from balatrobot.state import StateFile __version__ = "1.5.2" -__all__ = ["APIError", "BalatroClient", "BalatroInstance", "Config", "__version__"] +__all__ = [ + "APIError", + "BalatroClient", + "BalatroInstance", + "BalatroPool", + "Config", + "InstanceInfo", + "StateFile", + "__version__", +] From c7ce926f6772e00a19de933ec5a8d0ac0dbe9c6f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 10:34:59 +0200 Subject: [PATCH 027/121] refactor: replace UUID session IDs with timestamps Drop uuid-based session IDs in favor of human-readable timestamps for log directory names. Rename session_id to session_name throughout BalatroPool and BalatroInstance. --- src/balatrobot/instance.py | 14 ++++++++------ src/balatrobot/pool.py | 20 ++++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/balatrobot/instance.py b/src/balatrobot/instance.py index 10e4412c..b7f84799 100644 --- a/src/balatrobot/instance.py +++ b/src/balatrobot/instance.py @@ -19,20 +19,20 @@ class BalatroInstance: """Context manager for a single Balatro instance.""" def __init__( - self, config: Config | None = None, session_id: str | None = None, **overrides + self, config: Config | None = None, session_name: str | None = None, **overrides ) -> None: """Initialize a Balatro instance. Args: config: Base configuration. If None, uses Config from environment. - session_id: Optional session ID for log directory. If None, generated at start(). + session_name: Session directory name (timestamp). If None, generated at start(). **overrides: Override specific config fields (e.g., port=12347). """ base = config or Config.from_env() self._config = replace(base, **overrides) if overrides else base self._process: subprocess.Popen | None = None self._log_path: Path | None = None - self._session_id = session_id + self._session_name = session_name self._launcher: BaseLauncher | None = None @property @@ -79,9 +79,11 @@ async def start(self) -> None: if self._process is not None: raise RuntimeError("Instance already started") - # Create session directory (use provided session_id or generate one) - timestamp = self._session_id or datetime.now().strftime("%Y-%m-%dT%H-%M-%S") - session_dir = Path(self._config.logs_path) / timestamp + # Create session directory (use provided session_name or generate one) + session_name = self._session_name or datetime.now().strftime( + "%Y-%m-%dT%H-%M-%S" + ) + session_dir = Path(self._config.logs_path) / session_name session_dir.mkdir(parents=True, exist_ok=True) self._log_path = session_dir / f"{self._config.port}.log" diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py index b05ac36b..8688bf42 100644 --- a/src/balatrobot/pool.py +++ b/src/balatrobot/pool.py @@ -1,8 +1,8 @@ """BalatroPool — manages N BalatroInstance instances.""" import asyncio -import uuid from dataclasses import dataclass +from datetime import datetime from balatrobot.config import Config from balatrobot.instance import BalatroInstance @@ -47,7 +47,12 @@ def __init__( self._instances: list[BalatroInstance] = [] self._infos: list[InstanceInfo] = [] self._started = False - self._session_id: str | None = None + self._session_name: str | None = None + + @property + def session_name(self) -> str | None: + """Session directory name (timestamp), available after start().""" + return self._session_name @property def n(self) -> int: @@ -74,10 +79,11 @@ async def start(self) -> None: ports = self._ports else: from balatrobot.state import allocate_ports + ports = allocate_ports(self._n) - # Generate shared session ID - self._session_id = uuid.uuid4().hex[:12] + # Generate shared session directory name (timestamp) + self._session_name = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") # Create and start instances self._instances = [] @@ -87,14 +93,12 @@ async def start(self) -> None: for port in ports: inst = BalatroInstance( self._config, - session_id=self._session_id, + session_name=self._session_name, port=port, ) await inst.start() self._instances.append(inst) - self._infos.append( - InstanceInfo(host=self._config.host, port=port) - ) + self._infos.append(InstanceInfo(host=self._config.host, port=port)) except Exception: # Fail-fast: stop all instances that were started await self._stop_all() From 4943eb90f9aa492ca98d2334139cef75242a6c2b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 10:35:11 +0200 Subject: [PATCH 028/121] feat(serve): drop --host/--port options, validate --num-instances Remove --host and --port from serve command. Ports are now always ephemeral and discovered via state file. Add validation that --num-instances is >= 1. Print session name and logs directory instead of misleading PID label. --- src/balatrobot/cli/serve.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 6ef16cb0..0ed65920 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -15,14 +15,11 @@ def serve( # fmt: off - host: Annotated[ - str | None, typer.Option(help="Server hostname (default: 127.0.0.1)") - ] = None, - port: Annotated[ - int | None, typer.Option(help="Server port (default: 12346)") - ] = None, num_instances: Annotated[ - int, typer.Option("-n", "--num-instances", help="Number of instances to start (default: 1)") + int, + typer.Option( + "-n", "--num-instances", help="Number of instances to start (default: 1)" + ), ] = 1, fps_cap: Annotated[ int | None, typer.Option(help="Maximum FPS cap (default: 60)") @@ -76,10 +73,16 @@ def serve( ) raise typer.Exit(code=1) + # Validate num_instances + if num_instances < 1: + typer.echo( + f"Error: --num-instances must be >= 1, got {num_instances}.", + err=True, + ) + raise typer.Exit(code=1) + # Build config from kwargs with env var fallback config = Config.from_kwargs( - host=host, - port=port, fps_cap=fps_cap, gamespeed=gamespeed, animation_fps=animation_fps, @@ -111,6 +114,9 @@ async def _serve(config: Config, n: int) -> None: instances = sf.instances for i, info in enumerate(instances): typer.echo(f"Instance [{i}]: {info.url}") - typer.echo(f"PID: {sf._pool._session_id}. Press Ctrl+C to stop.") + session_name = pool.session_name + logs_dir = f"{config.logs_path}/{session_name}/" + typer.echo(f"Session: {session_name} | Logs: {logs_dir}") + typer.echo("Press Ctrl+C to stop.") while True: await asyncio.sleep(5) From fc88ad3fbea3be31049c53dc1b9e7ed1d1a8baf3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 10:35:23 +0200 Subject: [PATCH 029/121] fix(api): require --host and --port together Error if only one of --host or --port is provided. Both must be given for direct connection, or neither for state file discovery. --- src/balatrobot/cli/api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/balatrobot/cli/api.py b/src/balatrobot/cli/api.py index 29190838..e82b2177 100644 --- a/src/balatrobot/cli/api.py +++ b/src/balatrobot/cli/api.py @@ -54,6 +54,11 @@ def api( typer.echo(f"Error: Invalid JSON params - {e}", err=True) raise typer.Exit(code=1) + # Validate: --host and --port must be provided together or not at all + if (host is None) != (port is None): + typer.echo("Error: --host and --port must be provided together.", err=True) + raise typer.Exit(code=1) + # Resolve instance: explicit host+port, or discover from state file if host is not None and port is not None: target_host = host From 6c47781b67dd51b40ca01d16e8bee2b99459762d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 10:35:43 +0200 Subject: [PATCH 030/121] fix(state): guard pool cleanup on state file write failure Wrap pool.start() and _write_state() in try/except in StateFile.__aenter__. If writing the state file fails after the pool starts, stop the pool before re-raising to prevent orphaned processes. --- src/balatrobot/state.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/balatrobot/state.py b/src/balatrobot/state.py index 92fac207..02786953 100644 --- a/src/balatrobot/state.py +++ b/src/balatrobot/state.py @@ -17,7 +17,6 @@ from balatrobot.pool import BalatroPool, InstanceInfo - # --------------------------------------------------------------------------- # Port allocation # --------------------------------------------------------------------------- @@ -55,9 +54,7 @@ class StateFileBusy(StateFileError): def __init__(self, path: str | Path, pid: int) -> None: self.path = str(path) self.pid = pid - super().__init__( - f"State file {path!s} is locked by PID {pid}" - ) + super().__init__(f"State file {path!s} is locked by PID {pid}") class StateFileNotFound(StateFileError): @@ -65,7 +62,9 @@ class StateFileNotFound(StateFileError): def __init__(self, path: str | Path | None = None) -> None: self.path = str(path) if path is not None else None - super().__init__(f"No state file found at {path!s}" if path else "No state file found") + super().__init__( + f"No state file found at {path!s}" if path else "No state file found" + ) class InstanceNotFoundError(StateFileError): @@ -236,11 +235,13 @@ async def __aenter__(self) -> "StateFile": if existing is not None: raise StateFileBusy(path=self._path, pid=existing["pid"]) - # Start the pool - await self._pool.start() - - # Write state file atomically - self._write_state() + # Start the pool and write state file; clean up on failure + try: + await self._pool.start() + self._write_state() + except BaseException: + await self._pool.stop() + raise return self @@ -257,8 +258,7 @@ def _write_state(self) -> None: "pid": os.getpid(), "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "instances": [ - {"host": info.host, "port": info.port} - for info in self._pool.instances + {"host": info.host, "port": info.port} for info in self._pool.instances ], } # Ensure parent directory exists From 2efc7d2d7f425eedcf7c70c1bc444c88ed2d7494 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 10:35:53 +0200 Subject: [PATCH 031/121] fix(list): show session start time instead of PID Replace misleading PID output with Started timestamp in list command. --- src/balatrobot/cli/list.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/balatrobot/cli/list.py b/src/balatrobot/cli/list.py index 25fd8244..b536f46d 100644 --- a/src/balatrobot/cli/list.py +++ b/src/balatrobot/cli/list.py @@ -9,9 +9,7 @@ def list_cmd( - json_output: Annotated[ - bool, typer.Option("--json", help="Output as JSON") - ] = False, + json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False, ) -> None: """List running BalatroBot instances.""" data = StateFile.read() @@ -28,8 +26,8 @@ def list_cmd( return instances = data["instances"] - typer.echo(f"PID: {data['pid']}") - typer.echo(f"Started: {data['started_at']}") + started_at = data.get("started_at", "unknown") + typer.echo(f"Started: {started_at}") typer.echo(f"Instances ({len(instances)}):") for i, inst in enumerate(instances): typer.echo(f" [{i}] http://{inst['host']}:{inst['port']}") From 7fb9ccb8e5433671429980da025584f20e846000 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 10:36:03 +0200 Subject: [PATCH 032/121] test: update and add regression tests for CLI refactoring Update tests for session_id -> session_name rename, remove unused imports flagged by ruff, and add regression tests for: - serve -n 0 and negative values - api --host without --port (and vice versa) - StateFile cleanup on write failure - serve --help no longer shows --host/--port --- tests/cli/test_api_cmd.py | 52 ++++++++++++++++++++++++++++++----- tests/cli/test_list_cmd.py | 4 --- tests/cli/test_pool.py | 40 ++++++++++++++++----------- tests/cli/test_serve_cmd.py | 19 +++++++++++-- tests/cli/test_state.py | 29 ++++++++++++++++--- tests/lua/conftest.py | 5 ++-- tests/lua/core/test_server.py | 8 ++++-- 7 files changed, 120 insertions(+), 37 deletions(-) diff --git a/tests/cli/test_api_cmd.py b/tests/cli/test_api_cmd.py index 6466159b..019853e8 100644 --- a/tests/cli/test_api_cmd.py +++ b/tests/cli/test_api_cmd.py @@ -17,7 +17,9 @@ class TestApiCommand: def test_api_health_success(self, cli_port: int): """api health returns JSON result with explicit port.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"]) + result = runner.invoke( + app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "ok" @@ -25,7 +27,9 @@ def test_api_health_success(self, cli_port: int): def test_api_gamestate_success(self, cli_port: int, balatro_client: BalatroClient): """api gamestate returns state.""" balatro_client.call("menu") # Reset state - result = runner.invoke(app, ["api", "gamestate", "--port", str(cli_port), "--host", "127.0.0.1"]) + result = runner.invoke( + app, ["api", "gamestate", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 data = json.loads(result.output) assert "state" in data @@ -34,7 +38,10 @@ def test_api_with_params(self, cli_port: int, balatro_client: BalatroClient): """api command passes JSON params correctly.""" balatro_client.call("menu") params = json.dumps({"deck": "RED", "stake": "WHITE"}) - result = runner.invoke(app, ["api", "start", params, "--port", str(cli_port), "--host", "127.0.0.1"]) + result = runner.invoke( + app, + ["api", "start", params, "--port", str(cli_port), "--host", "127.0.0.1"], + ) assert result.exit_code == 0 # --- Method validation tests --- @@ -66,7 +73,9 @@ def test_api_invalid_json_params(self, cli_port: int): def test_api_empty_params_default(self, cli_port: int): """Empty params default to {}.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"]) + result = runner.invoke( + app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 # --- API error handling tests --- @@ -75,7 +84,16 @@ def test_api_error_formatted(self, cli_port: int, balatro_client: BalatroClient) """API errors formatted as 'Error: NAME - message'.""" balatro_client.call("menu") result = runner.invoke( - app, ["api", "play", '{"cards": [0]}', "--port", str(cli_port), "--host", "127.0.0.1"] + app, + [ + "api", + "play", + '{"cards": [0]}', + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], ) assert result.exit_code == 1 assert "Error: INVALID_STATE" in result.output @@ -84,7 +102,9 @@ def test_api_error_formatted(self, cli_port: int, balatro_client: BalatroClient) def test_api_connection_error(self): """Connection error formatted correctly.""" - result = runner.invoke(app, ["api", "health", "--port", "1", "--host", "127.0.0.1"]) + result = runner.invoke( + app, ["api", "health", "--port", "1", "--host", "127.0.0.1"] + ) assert result.exit_code == 1 assert "Connection failed" in result.output @@ -92,7 +112,9 @@ def test_api_connection_error(self): def test_api_output_is_indented_json(self, cli_port: int): """Output is pretty-printed JSON.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"]) + result = runner.invoke( + app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 # Check for indentation (2 spaces) or compact format assert ' "status"' in result.output or '"status": "ok"' in result.output @@ -104,3 +126,19 @@ def test_api_no_state_file_error(self, tmp_path, monkeypatch): monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) result = runner.invoke(app, ["api", "health"]) assert result.exit_code == 1 + + # --- Host/port validation tests --- + + def test_api_host_without_port(self, tmp_path, monkeypatch): + """--host without --port rejected.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["api", "health", "--host", "127.0.0.1"]) + assert result.exit_code == 1 + assert "--host and --port must be provided together" in result.output + + def test_api_port_without_host(self, tmp_path, monkeypatch): + """--port without --host rejected.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["api", "health", "--port", "12346"]) + assert result.exit_code == 1 + assert "--host and --port must be provided together" in result.output diff --git a/tests/cli/test_list_cmd.py b/tests/cli/test_list_cmd.py index e8f949b3..407787e8 100644 --- a/tests/cli/test_list_cmd.py +++ b/tests/cli/test_list_cmd.py @@ -2,14 +2,10 @@ import json import os -from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner from balatrobot.cli import app -from balatrobot.pool import InstanceInfo -from balatrobot.state import StateFile runner = CliRunner() diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py index 212d7410..465d2649 100644 --- a/tests/cli/test_pool.py +++ b/tests/cli/test_pool.py @@ -1,6 +1,5 @@ """Tests for balatrobot.pool module.""" -import asyncio from dataclasses import FrozenInstanceError from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +9,6 @@ from balatrobot.instance import BalatroInstance from balatrobot.pool import BalatroPool, InstanceInfo - # ============================================================================ # InstanceInfo tests # ============================================================================ @@ -34,7 +32,7 @@ def test_frozen(self): """InstanceInfo is immutable.""" info = InstanceInfo(host="127.0.0.1", port=12346) with pytest.raises(FrozenInstanceError): - info.port = 9999 # type: ignore[misc] + setattr(info, "port", 9999) def test_equality(self): """Two InstanceInfo with same values are equal.""" @@ -109,7 +107,9 @@ def mock_instance_factory(config_arg, **kwargs): created_instances.append(inst) return inst - with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): pool = BalatroPool(config, ports=[14001, 14002]) await pool.start() @@ -157,7 +157,9 @@ async def test_start_fail_cleans_up(self, tmp_path): failed_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) failed_inst.stop = AsyncMock() - with patch("balatrobot.pool.BalatroInstance", side_effect=[started_inst, failed_inst]): + with patch( + "balatrobot.pool.BalatroInstance", side_effect=[started_inst, failed_inst] + ): pool = BalatroPool(config, ports=[14001, 14002]) with pytest.raises(RuntimeError, match="start failed"): await pool.start() @@ -259,7 +261,9 @@ def mock_instance_factory(config_arg, **kwargs): inst.stop = AsyncMock() return inst - with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): await pool.start() assert len(captured_ports) == 2 @@ -289,7 +293,9 @@ def mock_instance_factory(config_arg, **kwargs): inst.stop = AsyncMock() return inst - with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): pool = BalatroPool(config, ports=[14001, 14002]) await pool.start() @@ -302,27 +308,29 @@ def mock_instance_factory(config_arg, **kwargs): await pool.stop() @pytest.mark.asyncio - async def test_shared_session_id(self, tmp_path): - """Pool generates a shared session ID for all instances.""" + async def test_shared_session_name(self, tmp_path): + """Pool generates a shared session name for all instances.""" config = Config(logs_path=str(tmp_path)) - captured_session_ids = [] + captured_session_names = [] def mock_instance_factory(config_arg, **kwargs): - session_id = kwargs.get("session_id") - captured_session_ids.append(session_id) + session_name = kwargs.get("session_name") + captured_session_names.append(session_name) inst = MagicMock(spec=BalatroInstance) inst.port = kwargs.get("port", 12346) inst.start = AsyncMock() inst.stop = AsyncMock() return inst - with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory): + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): pool = BalatroPool(config, ports=[14001, 14002]) await pool.start() - # All instances share the same session ID - assert len(set(captured_session_ids)) == 1 - assert captured_session_ids[0] is not None + # All instances share the same session name + assert len(set(captured_session_names)) == 1 + assert captured_session_names[0] is not None await pool.stop() diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index bea014fd..950c4c29 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -24,18 +24,33 @@ def test_serve_valid_platforms(self): """All valid platforms in list.""" assert PLATFORM_CHOICES == ["darwin", "linux", "windows", "native"] + # --- Num instances validation tests --- + + def test_serve_num_instances_zero(self): + """--num-instances 0 rejected with error message.""" + result = runner.invoke(app, ["serve", "-n", "0"]) + assert result.exit_code == 1 + assert "--num-instances must be >= 1" in result.output + + def test_serve_num_instances_negative(self): + """Negative --num-instances rejected.""" + result = runner.invoke(app, ["serve", "-n", "-1"]) + assert result.exit_code == 1 + assert "--num-instances must be >= 1" in result.output + # --- Help text tests --- def test_serve_help(self): """serve --help shows all options.""" result = runner.invoke(app, ["serve", "--help"]) assert result.exit_code == 0 - assert "--host" in result.output - assert "--port" in result.output assert "--fast" in result.output assert "--headless" in result.output assert "--platform" in result.output assert "--num-instances" in result.output or "-n" in result.output + # --host and --port removed from serve (ephemeral ports, state file discovery) + assert "--host" not in result.output + assert "--port" not in result.output # --- Config.from_kwargs tests --- diff --git a/tests/cli/test_state.py b/tests/cli/test_state.py index 90a408d4..9a5c234b 100644 --- a/tests/cli/test_state.py +++ b/tests/cli/test_state.py @@ -2,14 +2,12 @@ import json import os -import tempfile -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from balatrobot.config import Config -from balatrobot.pool import BalatroPool, InstanceInfo +from balatrobot.pool import BalatroPool from balatrobot.state import ( InstanceNotFoundError, StateFile, @@ -19,7 +17,6 @@ allocate_ports, ) - # ============================================================================ # allocate_ports tests # ============================================================================ @@ -367,6 +364,30 @@ async def test_stale_state_does_not_raise_busy(self, tmp_path): # Should succeed — stale file cleaned up assert sf.is_started is True + @pytest.mark.asyncio + async def test_write_failure_stops_pool(self, tmp_path): + """Pool is stopped if state file write fails after pool start.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + pool = BalatroPool(config, ports=[14001]) + sf = StateFile(pool, path=state_path) + + # Make _write_state fail after pool starts + with patch.object(sf, "_write_state", side_effect=OSError("disk full")): + with pytest.raises(OSError, match="disk full"): + async with sf: + pass + + # Pool should have been stopped (stop called on mock_inst) + mock_inst.stop.assert_called() + # ============================================================================ # StateFile path resolution tests diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index e0136e8c..5feb13e1 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -15,7 +15,6 @@ from balatrobot.config import Config from balatrobot.instance import BalatroInstance from balatrobot.pool import InstanceInfo -from balatrobot.state import StateFile # ============================================================================ # Constants @@ -155,7 +154,9 @@ async def balatro_server(instance: InstanceInfo) -> AsyncGenerator[None, None]: @pytest.fixture -def client(instance: InstanceInfo, balatro_server) -> Generator[httpx.Client, None, None]: +def client( + instance: InstanceInfo, balatro_server +) -> Generator[httpx.Client, None, None]: """Create an HTTP client connected to Balatro game instance. Args: diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index 3eca1344..4fa30f11 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -24,14 +24,18 @@ class TestHTTPServerInit: """Tests for HTTP server initialization and port binding.""" - def test_server_binds_to_configured_port(self, instance: InstanceInfo, balatro_server) -> None: + def test_server_binds_to_configured_port( + self, instance: InstanceInfo, balatro_server + ) -> None: """Test that server is listening on the expected port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(2) sock.connect(("127.0.0.1", instance.port)) assert sock.fileno() != -1, f"Should connect to port {instance.port}" - def test_port_is_exclusively_bound(self, instance: InstanceInfo, balatro_server) -> None: + def test_port_is_exclusively_bound( + self, instance: InstanceInfo, balatro_server + ) -> None: """Test that server exclusively binds the port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with pytest.raises(OSError) as exc_info: From 092862b81294b5ea86a985171123c4ea05d53dcc Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 11:36:56 +0200 Subject: [PATCH 033/121] feat(cli): expose log_path in InstanceInfo and list output Add optional log_path field to InstanceInfo, propagate it through BalatroPool and StateFile, and display it in No running instances. output. --- src/balatrobot/cli/list.py | 2 +- src/balatrobot/pool.py | 8 +++- src/balatrobot/state.py | 13 +++-- tests/cli/test_list_cmd.py | 21 ++++++-- tests/cli/test_pool.py | 18 +++++++ tests/cli/test_state.py | 98 +++++++++++++++++++++++++++++++++----- 6 files changed, 138 insertions(+), 22 deletions(-) diff --git a/src/balatrobot/cli/list.py b/src/balatrobot/cli/list.py index b536f46d..4ccc7ead 100644 --- a/src/balatrobot/cli/list.py +++ b/src/balatrobot/cli/list.py @@ -30,4 +30,4 @@ def list_cmd( typer.echo(f"Started: {started_at}") typer.echo(f"Instances ({len(instances)}):") for i, inst in enumerate(instances): - typer.echo(f" [{i}] http://{inst['host']}:{inst['port']}") + typer.echo(f" [{i}] http://{inst['host']}:{inst['port']} log: {inst['log_path']}") diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py index 8688bf42..b3b7331e 100644 --- a/src/balatrobot/pool.py +++ b/src/balatrobot/pool.py @@ -10,10 +10,11 @@ @dataclass(frozen=True) class InstanceInfo: - """Immutable connection info for a running Balatro instance.""" + """Immutable metadata for a running Balatro instance.""" host: str port: int + log_path: str | None = None @property def url(self) -> str: @@ -98,7 +99,10 @@ async def start(self) -> None: ) await inst.start() self._instances.append(inst) - self._infos.append(InstanceInfo(host=self._config.host, port=port)) + log_path = str(inst.log_path) if inst.log_path is not None else None + self._infos.append( + InstanceInfo(host=self._config.host, port=port, log_path=log_path) + ) except Exception: # Fail-fast: stop all instances that were started await self._stop_all() diff --git a/src/balatrobot/state.py b/src/balatrobot/state.py index 02786953..c1eb8bd5 100644 --- a/src/balatrobot/state.py +++ b/src/balatrobot/state.py @@ -215,7 +215,11 @@ def resolve( if host is not None and port is not None: for inst in instances: if inst["host"] == host and inst["port"] == port: - return InstanceInfo(host=inst["host"], port=inst["port"]) + return InstanceInfo( + host=inst["host"], + port=inst["port"], + log_path=inst["log_path"], + ) raise InstanceNotFoundError(index=None, total=len(instances)) # Index-based lookup (default to 0) @@ -224,7 +228,9 @@ def resolve( raise InstanceNotFoundError(index=idx, total=len(instances)) inst = instances[idx] - return InstanceInfo(host=inst["host"], port=inst["port"]) + return InstanceInfo( + host=inst["host"], port=inst["port"], log_path=inst["log_path"] + ) # -- Context manager ---------------------------------------------------- @@ -258,7 +264,8 @@ def _write_state(self) -> None: "pid": os.getpid(), "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "instances": [ - {"host": info.host, "port": info.port} for info in self._pool.instances + {"host": info.host, "port": info.port, "log_path": info.log_path} + for info in self._pool.instances ], } # Ensure parent directory exists diff --git a/tests/cli/test_list_cmd.py b/tests/cli/test_list_cmd.py index 407787e8..92117418 100644 --- a/tests/cli/test_list_cmd.py +++ b/tests/cli/test_list_cmd.py @@ -30,8 +30,16 @@ def test_list_with_instances(self, tmp_path, monkeypatch): "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", "instances": [ - {"host": "127.0.0.1", "port": 14001}, - {"host": "127.0.0.1", "port": 14002}, + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, ], } state_path.write_text(json.dumps(state_data)) @@ -40,6 +48,8 @@ def test_list_with_instances(self, tmp_path, monkeypatch): assert result.exit_code == 0 assert "14001" in result.output assert "14002" in result.output + assert "/tmp/logs/s/14001.log" in result.output + assert "/tmp/logs/s/14002.log" in result.output def test_list_json_output(self, tmp_path, monkeypatch): """List --json outputs structured JSON.""" @@ -50,7 +60,11 @@ def test_list_json_output(self, tmp_path, monkeypatch): "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", "instances": [ - {"host": "127.0.0.1", "port": 14001}, + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, ], } state_path.write_text(json.dumps(state_data)) @@ -60,6 +74,7 @@ def test_list_json_output(self, tmp_path, monkeypatch): data = json.loads(result.output) assert len(data["instances"]) == 1 assert data["instances"][0]["port"] == 14001 + assert data["instances"][0]["log_path"] == "/tmp/logs/s/14001.log" def test_list_help(self): """List --help shows options.""" diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py index 465d2649..635cc968 100644 --- a/tests/cli/test_pool.py +++ b/tests/cli/test_pool.py @@ -22,6 +22,12 @@ def test_create_with_host_port(self): info = InstanceInfo(host="127.0.0.1", port=12346) assert info.host == "127.0.0.1" assert info.port == 12346 + assert info.log_path is None + + def test_create_with_log_path(self): + """InstanceInfo stores log_path.""" + info = InstanceInfo(host="127.0.0.1", port=12346, log_path="/tmp/test.log") + assert info.log_path == "/tmp/test.log" def test_url_property(self): """url property returns formatted URL.""" @@ -102,6 +108,7 @@ def mock_instance_factory(config_arg, **kwargs): inst = MagicMock(spec=BalatroInstance) port = kwargs.get("port", 12346) inst.port = port + inst.log_path = f"/tmp/test-logs/{port}.log" inst.start = AsyncMock() inst.stop = AsyncMock() created_instances.append(inst) @@ -129,6 +136,7 @@ async def test_stop_concurrent(self, tmp_path): for port in [14001, 14002]: inst = MagicMock(spec=BalatroInstance) inst.port = port + inst.log_path = f"/tmp/test-logs/{port}.log" inst.start = AsyncMock() inst.stop = AsyncMock() mock_instances.append(inst) @@ -149,11 +157,13 @@ async def test_start_fail_cleans_up(self, tmp_path): started_inst = MagicMock(spec=BalatroInstance) started_inst.port = 14001 + started_inst.log_path = "/tmp/test-logs/14001.log" started_inst.start = AsyncMock() started_inst.stop = AsyncMock() failed_inst = MagicMock(spec=BalatroInstance) failed_inst.port = 14002 + failed_inst.log_path = "/tmp/test-logs/14002.log" failed_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) failed_inst.stop = AsyncMock() @@ -184,6 +194,7 @@ async def test_start_already_started(self, tmp_path): mock_inst = MagicMock(spec=BalatroInstance) mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() @@ -202,6 +213,7 @@ async def test_instances_populated_after_start(self, tmp_path): for port in [14001, 14002]: inst = MagicMock(spec=BalatroInstance) inst.port = port + inst.log_path = f"/tmp/test-logs/{port}.log" inst.start = AsyncMock() inst.stop = AsyncMock() mock_instances.append(inst) @@ -216,6 +228,8 @@ async def test_instances_populated_after_start(self, tmp_path): assert infos[0].port == 14001 assert infos[1].port == 14002 assert infos[0].host == config.host + assert infos[0].log_path == "/tmp/test-logs/14001.log" + assert infos[1].log_path == "/tmp/test-logs/14002.log" await pool.stop() @@ -230,6 +244,7 @@ async def test_context_manager(self, tmp_path): mock_inst = MagicMock(spec=BalatroInstance) mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() @@ -257,6 +272,7 @@ def mock_instance_factory(config_arg, **kwargs): captured_ports.append(port) inst = MagicMock(spec=BalatroInstance) inst.port = port + inst.log_path = f"/tmp/test-logs/{port}.log" inst.start = AsyncMock() inst.stop = AsyncMock() return inst @@ -289,6 +305,7 @@ def mock_instance_factory(config_arg, **kwargs): captured_overrides.append(kwargs) inst = MagicMock(spec=BalatroInstance) inst.port = kwargs.get("port", 12346) + inst.log_path = f"/tmp/test-logs/{kwargs.get('port', 12346)}.log" inst.start = AsyncMock() inst.stop = AsyncMock() return inst @@ -319,6 +336,7 @@ def mock_instance_factory(config_arg, **kwargs): captured_session_names.append(session_name) inst = MagicMock(spec=BalatroInstance) inst.port = kwargs.get("port", 12346) + inst.log_path = f"/tmp/test-logs/{kwargs.get('port', 12346)}.log" inst.start = AsyncMock() inst.stop = AsyncMock() return inst diff --git a/tests/cli/test_state.py b/tests/cli/test_state.py index 9a5c234b..a2d53600 100644 --- a/tests/cli/test_state.py +++ b/tests/cli/test_state.py @@ -99,8 +99,16 @@ def test_read_valid_state(self, tmp_path): "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", "instances": [ - {"host": "127.0.0.1", "port": 14001}, - {"host": "127.0.0.1", "port": 14002}, + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, ], } state_path.write_text(json.dumps(state_data)) @@ -121,7 +129,13 @@ def test_read_stale_state_auto_deletes(self, tmp_path): state_data = { "pid": 999999999, # Non-existent PID "started_at": "2026-05-28T12:00:00Z", - "instances": [{"host": "127.0.0.1", "port": 14001}], + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], } state_path.write_text(json.dumps(state_data)) @@ -159,8 +173,16 @@ def test_resolve_by_host_port(self, tmp_path, monkeypatch): "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", "instances": [ - {"host": "127.0.0.1", "port": 14001}, - {"host": "127.0.0.1", "port": 14002}, + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, ], } state_path.write_text(json.dumps(state_data)) @@ -168,6 +190,7 @@ def test_resolve_by_host_port(self, tmp_path, monkeypatch): info = StateFile.resolve(host="127.0.0.1", port=14002) assert info.port == 14002 + assert info.log_path == "/tmp/logs/s/14002.log" def test_resolve_by_index(self, tmp_path, monkeypatch): """Resolves by index.""" @@ -176,8 +199,16 @@ def test_resolve_by_index(self, tmp_path, monkeypatch): "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", "instances": [ - {"host": "127.0.0.1", "port": 14001}, - {"host": "127.0.0.1", "port": 14002}, + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, ], } state_path.write_text(json.dumps(state_data)) @@ -185,6 +216,7 @@ def test_resolve_by_index(self, tmp_path, monkeypatch): info = StateFile.resolve(index=1) assert info.port == 14002 + assert info.log_path == "/tmp/logs/s/14002.log" def test_resolve_no_state_file(self, tmp_path, monkeypatch): """Raises StateFileNotFound when no state file.""" @@ -212,7 +244,13 @@ def test_resolve_index_out_of_range(self, tmp_path, monkeypatch): state_data = { "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", - "instances": [{"host": "127.0.0.1", "port": 14001}], + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], } state_path.write_text(json.dumps(state_data)) monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) @@ -229,8 +267,16 @@ def test_resolve_default_index_zero(self, tmp_path, monkeypatch): "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", "instances": [ - {"host": "127.0.0.1", "port": 14001}, - {"host": "127.0.0.1", "port": 14002}, + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, ], } state_path.write_text(json.dumps(state_data)) @@ -238,6 +284,7 @@ def test_resolve_default_index_zero(self, tmp_path, monkeypatch): info = StateFile.resolve() assert info.port == 14001 # index=0 by default + assert info.log_path == "/tmp/logs/s/14001.log" def test_resolve_host_port_not_in_instances(self, tmp_path, monkeypatch): """Raises InstanceNotFoundError when host:port not found in instances.""" @@ -245,7 +292,13 @@ def test_resolve_host_port_not_in_instances(self, tmp_path, monkeypatch): state_data = { "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", - "instances": [{"host": "127.0.0.1", "port": 14001}], + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], } state_path.write_text(json.dumps(state_data)) monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) @@ -270,6 +323,7 @@ async def test_context_manager_writes_state(self, tmp_path): mock_inst = MagicMock() mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() @@ -282,6 +336,7 @@ async def test_context_manager_writes_state(self, tmp_path): assert data["pid"] == os.getpid() assert len(data["instances"]) == 1 assert data["instances"][0]["port"] == 14001 + assert data["instances"][0]["log_path"] == "/tmp/test-logs/14001.log" assert "started_at" in data assert not state_path.exists() @@ -293,6 +348,7 @@ async def test_delegates_instances(self, tmp_path): mock_inst = MagicMock() mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() @@ -302,6 +358,7 @@ async def test_delegates_instances(self, tmp_path): async with sf: assert len(sf.instances) == 1 assert sf.instances[0].port == 14001 + assert sf.instances[0].log_path == "/tmp/test-logs/14001.log" @pytest.mark.asyncio async def test_path_property(self, tmp_path): @@ -321,13 +378,20 @@ async def test_double_start_raises_busy(self, tmp_path): state_data = { "pid": os.getpid(), "started_at": "2026-05-28T12:00:00Z", - "instances": [{"host": "127.0.0.1", "port": 14001}], + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], } state_path.write_text(json.dumps(state_data)) config = Config(logs_path=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() @@ -347,13 +411,20 @@ async def test_stale_state_does_not_raise_busy(self, tmp_path): state_data = { "pid": 999999999, "started_at": "2026-05-28T12:00:00Z", - "instances": [{"host": "127.0.0.1", "port": 14001}], + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], } state_path.write_text(json.dumps(state_data)) config = Config(logs_path=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() @@ -372,6 +443,7 @@ async def test_write_failure_stops_pool(self, tmp_path): mock_inst = MagicMock() mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() From 21367ec0cf86ddabd5a685f148e9cbf6514807d8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 11:37:13 +0200 Subject: [PATCH 034/121] docs(skills): rewrite balatrobot SKILL.md Restructure runbook around serve/list/api commands with examples, auto-discovery notes, and a logs section. --- .agents/skills/balatrobot/SKILL.md | 73 ++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index 7c45e84b..6052aaaa 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -3,35 +3,70 @@ name: balatrobot description: Launch Balatro with the BalatroBot mod and interact via the CLI. Use when you need to manually test, reproduce issues, or inspect game state through the JSON-RPC API. --- -# BalatroBot CLI runbook +# BalatroBot CLI -Run commands from the repo root. Use `balatrobot ...` only (no `curl`, no `uvx`). -Help is available with `balatrobot --help`. +Three commands: `serve`, `api`, `list`. Explore any with `--help`. -## Start a session +## `serve` — start Balatro -Pick a random port in 20000–30000 to avoid conflicts: +```bash +balatrobot serve --help +``` + +Typical invocation: ```bash -PORT="$((20000 + RANDOM % 10001))" -balatrobot serve --port "$PORT" --headless --fast --debug +balatrobot serve --headless --fast --debug ``` -Help is available with `balatrobot serve --help`. -Use `--render-on-api` instead of `--headless` when you need screenshots. +Key flags: `--headless`, `--fast` (10× speed), `--debug` (DebugPlus logging), `-n`/`--num-instances` (pool). -## Call the API (in a second terminal) +All flags have `BALATROBOT_*` env var equivalents (e.g. `BALATROBOT_FAST=1`). See `src/balatrobot/config.py` for the full mapping. + +`serve` auto-allocates ports, prints instance URLs and the session logs directory, then blocks until Ctrl+C. It writes a state file so other commands can discover the running instances. + +## `list` — show running instances ```bash -balatrobot api health --port "$PORT" -balatrobot api gamestate --port "$PORT" -balatrobot api start '{"deck":"RED","stake":"WHITE"}' --port "$PORT" -balatrobot api select --port "$PORT" -balatrobot api play '{"cards":[0,1,2,3,4]}' --port "$PORT" -balatrobot api menu --port "$PORT" +balatrobot list # human-readable +balatrobot list --json # machine-readable (pipe to jq) ``` -Help is available with `balatrobot serve --help`. -Pipe to `jq` to filter responses. Example: `balatrobot api gamestate --port "$PORT" | jq '.state'`. +Shows instances from the current session's state file, including per-instance log paths. Use `--json` and pipe to `jq` to extract specific fields. + +## `api` — call endpoints + +```bash +balatrobot api [JSON_PARAMS] +balatrobot api --help +``` + +Auto-discovers the running instance from the state file — no `--host`/`--port` needed for single-instance sessions. For multi-instance pools, use `-i`/`--index` (0-based, default 0). + +Params are a JSON string (default `{}`). Examples: + +```bash +balatrobot api health +balatrobot api gamestate +balatrobot api start '{"deck":"RED","stake":"WHITE"}' +balatrobot api select +balatrobot api play '{"cards":[0,1,2,3,4]}' +balatrobot api discard '{"cards":[0,1]}' +... +``` + +Output is pretty-printed JSON. Pipe to `jq` for filtering: + +```bash +balatrobot api gamestate | jq '.state' +balatrobot api gamestate | jq '{state, money, hand: .hand.count}' +``` + +API errors surface as ` - ` on stderr (e.g. `INVALID_STATE`, `BAD_REQUEST`). +Full API reference (methods, errors, states): `docs/api.md`. + +## Logs + +Each instance has a log file (Balatro/Love2D output, Lovely traces, Lua errors, HTTP server logs). Find log paths via `balatrobot list` or `balatrobot list --json | jq '.instances[].log_path'`. -Full API reference: `docs/api.md`. +Logs are stored under `logs//.log` (configurable via `--logs-path`). When `serve` fails or endpoints behave unexpectedly, check the log file. From 742e8d4782525500a97f22d5e1c0b82f1b2beedf Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:14:56 +0200 Subject: [PATCH 035/121] refactor(instance): add InstanceDiedError and check_alive method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add InstanceDiedError exception for subprocess death detection and check_alive() sync method that polls the subprocess and raises when the process has exited. Purely additive — no existing behavior changed. --- src/balatrobot/instance.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/balatrobot/instance.py b/src/balatrobot/instance.py index b7f84799..0f0d1139 100644 --- a/src/balatrobot/instance.py +++ b/src/balatrobot/instance.py @@ -15,6 +15,18 @@ HEALTH_TIMEOUT = 30.0 +class InstanceDiedError(Exception): + """Raised when a Balatro subprocess has exited unexpectedly.""" + + def __init__(self, port: int, log_path: str | None = None) -> None: + self.port = port + self.log_path = log_path + msg = f"Instance on port {port} died unexpectedly." + if log_path is not None: + msg += f"\nLog: {log_path}" + super().__init__(msg) + + class BalatroInstance: """Context manager for a single Balatro instance.""" @@ -131,6 +143,20 @@ async def stop(self) -> None: process.kill() await loop.run_in_executor(None, process.wait) + def check_alive(self) -> None: + """Check if the subprocess is still running. + + Raises InstanceDiedError if the process has exited. + Silently returns if the instance hasn't been started or is already stopped. + """ + if self._process is None: + return + if self._process.poll() is not None: + raise InstanceDiedError( + port=self._config.port, + log_path=str(self._log_path) if self._log_path is not None else None, + ) + async def __aenter__(self) -> "BalatroInstance": """Start instance on context entry.""" await self.start() From bbf4858eebf7945c73ac0a2b1915876b08dbc265 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:15:04 +0200 Subject: [PATCH 036/121] refactor(pool): add check_alive, remove context manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add check_alive() that delegates to each instance's check_alive(). Remove __aenter__/__aexit__ — lifecycle is now managed by the caller (Server) rather than the pool itself. --- src/balatrobot/pool.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py index b3b7331e..3599fc8a 100644 --- a/src/balatrobot/pool.py +++ b/src/balatrobot/pool.py @@ -128,9 +128,10 @@ async def _stop_all(self) -> None: self._infos = [] self._started = False - async def __aenter__(self) -> "BalatroPool": - await self.start() - return self + def check_alive(self) -> None: + """Check all instances are still running. - async def __aexit__(self, *args) -> None: - await self.stop() + Raises InstanceDiedError from the first dead instance found. + """ + for inst in self._instances: + inst.check_alive() From d1a011ccb3a8e564101cd721f3d1cca0dcbed9f6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:15:13 +0200 Subject: [PATCH 037/121] refactor(state): convert StateFile to static utility class Replace instance-based context manager with static write()/delete() methods. Remove __init__, __aenter__/__aexit__, _write_state, _delete_state, and all instance properties (path, instances, is_started). Lifecycle ownership moves to the new Server class. --- src/balatrobot/state.py | 100 +++++++++++++--------------------------- 1 file changed, 32 insertions(+), 68 deletions(-) diff --git a/src/balatrobot/state.py b/src/balatrobot/state.py index c1eb8bd5..16989898 100644 --- a/src/balatrobot/state.py +++ b/src/balatrobot/state.py @@ -1,8 +1,8 @@ -"""StateFile — filesystem-based discovery for running BalatroPool instances. +"""StateFile — static utilities for BalatroPool state-file discovery. -StateFile wraps a BalatroPool with a JSON state file (Jupyter pattern). -It writes connection info atomically on pool start and deletes it on pool -stop, enabling discovery by CLI tools and test fixtures. +Provides read / write / delete / resolve helpers for the JSON state file +that enables discovery of running BalatroPool instances by CLI tools and +test fixtures. """ import json @@ -15,7 +15,7 @@ from platformdirs import user_state_dir -from balatrobot.pool import BalatroPool, InstanceInfo +from balatrobot.pool import InstanceInfo # --------------------------------------------------------------------------- # Port allocation @@ -113,39 +113,12 @@ def _is_pid_alive(pid: int) -> bool: class StateFile: - """Wraps a :class:`BalatroPool` with filesystem-based discovery. + """Static utilities for reading, writing, and resolving state files. - Usage:: - - async with StateFile(BalatroPool(config, n=2)) as sf: - # state file written; other processes can discover instances - print(sf.instances) - # state file deleted on exit + All methods are static. The state file is a JSON document that enables + discovery of running BalatroPool instances by CLI tools and test fixtures. """ - def __init__( - self, - pool: BalatroPool, - path: Path | None = None, - ) -> None: - self._pool = pool - self._path = path or _default_state_path() - - @property - def path(self) -> Path: - """Resolved state file path.""" - return self._path - - @property - def instances(self) -> list[InstanceInfo]: - """Delegates to pool.instances.""" - return self._pool.instances - - @property - def is_started(self) -> bool: - """Delegates to pool.is_started.""" - return self._pool.is_started - # -- Static helpers ----------------------------------------------------- @staticmethod @@ -232,55 +205,45 @@ def resolve( host=inst["host"], port=inst["port"], log_path=inst["log_path"] ) - # -- Context manager ---------------------------------------------------- - - async def __aenter__(self) -> "StateFile": - """Check for existing live PID, start pool, write state file.""" - # Check for existing live state file - existing = StateFile.read(self._path) - if existing is not None: - raise StateFileBusy(path=self._path, pid=existing["pid"]) - - # Start the pool and write state file; clean up on failure - try: - await self._pool.start() - self._write_state() - except BaseException: - await self._pool.stop() - raise + # -- Write / Delete ---------------------------------------------------- - return self - - async def __aexit__(self, *args: object) -> None: - """Delete state file and stop pool.""" - self._delete_state() - await self._pool.stop() + @staticmethod + def write( + path: Path, + pid: int, + instances: list[InstanceInfo], + ) -> None: + """Write a state file atomically. - # -- Internal ----------------------------------------------------------- + Creates parent directories if needed. Uses temp file + ``os.replace`` + for atomicity. - def _write_state(self) -> None: - """Write state file atomically.""" + Args: + path: Destination file path. + pid: Process ID of the server. + instances: List of InstanceInfo to record. + """ data = { - "pid": os.getpid(), + "pid": pid, "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "instances": [ {"host": info.host, "port": info.port, "log_path": info.log_path} - for info in self._pool.instances + for info in instances ], } # Ensure parent directory exists - self._path.parent.mkdir(parents=True, exist_ok=True) + path.parent.mkdir(parents=True, exist_ok=True) # Atomic write: write to temp file, then rename fd, tmp_path = tempfile.mkstemp( - dir=str(self._path.parent), + dir=str(path.parent), prefix=".state-", suffix=".tmp", ) try: with os.fdopen(fd, "w") as f: json.dump(data, f) - os.replace(tmp_path, str(self._path)) + os.replace(tmp_path, str(path)) except BaseException: # Clean up temp file on error try: @@ -289,9 +252,10 @@ def _write_state(self) -> None: pass raise - def _delete_state(self) -> None: - """Delete state file.""" + @staticmethod + def delete(path: Path) -> None: + """Delete a state file. Silent if the file doesn't exist.""" try: - self._path.unlink(missing_ok=True) + path.unlink(missing_ok=True) except OSError: pass From f34931a424140f01fdb59b8915c830166efd0f40 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:15:24 +0200 Subject: [PATCH 038/121] refactor(serve): introduce Server class owning serve lifecycle Add Server async context manager that owns pool start/stop, state file write/delete, and a run() supervision loop (SIGTERM + check_alive). Rewrite _serve() and serve() to use Server, replacing the old StateFile context manager. Add InstanceDiedError and StateFileBusy exception handling in serve(). --- src/balatrobot/cli/serve.py | 96 ++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 0ed65920..ad0c831c 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -1,18 +1,88 @@ """Serve command — start Balatro with BalatroBot mod loaded.""" import asyncio +import os +import signal +from pathlib import Path from typing import Annotated import typer from balatrobot.config import Config +from balatrobot.instance import InstanceDiedError from balatrobot.pool import BalatroPool -from balatrobot.state import StateFile +from balatrobot.state import StateFile, StateFileBusy, _default_state_path # Platform choices for validation PLATFORM_CHOICES = ["darwin", "linux", "windows", "native"] +class Server: + """Owns the full serve lifecycle: pool start/stop, state file write/delete, + and a supervision loop that watches for SIGTERM or child-death. + + Usage:: + + async with Server(config, n=2) as server: + await server.run() + """ + + def __init__( + self, + config: Config, + n: int, + state_path: Path | None = None, + ) -> None: + self._config = config + self._n = n + self._state_path = state_path or _default_state_path() + self._pool: BalatroPool | None = None + self._shutdown = asyncio.Event() + + @property + def pool(self) -> BalatroPool | None: + return self._pool + + async def __aenter__(self) -> "Server": + # 1. Check for existing live state file + existing = StateFile.read(self._state_path) + if existing is not None: + raise StateFileBusy(path=self._state_path, pid=existing["pid"]) + + # 2. Start pool + self._pool = BalatroPool(self._config, n=self._n) + try: + await self._pool.start() + # 3. Write state file + StateFile.write(self._state_path, os.getpid(), self._pool.instances) + except BaseException: + await self._pool.stop() + raise + + return self + + async def __aexit__(self, *args: object) -> None: + StateFile.delete(self._state_path) + if self._pool is not None: + await self._pool.stop() + + async def run(self) -> None: + """Block until SIGTERM or child death. + + Raises InstanceDiedError on child death. + """ + assert self._pool is not None # set by __aenter__ + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGTERM, self._shutdown.set) + + while not self._shutdown.is_set(): + self._pool.check_alive() + try: + await asyncio.wait_for(self._shutdown.wait(), timeout=5) + except asyncio.TimeoutError: + pass + + def serve( # fmt: off num_instances: Annotated[ @@ -105,18 +175,22 @@ def serve( asyncio.run(_serve(config, num_instances)) except KeyboardInterrupt: typer.echo("\nShutting down server...") + except InstanceDiedError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) + except StateFileBusy as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) async def _serve(config: Config, n: int) -> None: - """Async serve implementation using StateFile + BalatroPool.""" - pool = BalatroPool(config, n=n) - async with StateFile(pool) as sf: - instances = sf.instances - for i, info in enumerate(instances): + async with Server(config, n) as server: + pool = server.pool + assert pool is not None + for i, info in enumerate(pool.instances): typer.echo(f"Instance [{i}]: {info.url}") - session_name = pool.session_name - logs_dir = f"{config.logs_path}/{session_name}/" - typer.echo(f"Session: {session_name} | Logs: {logs_dir}") + typer.echo( + f"Session: {pool.session_name} | Logs: {config.logs_path}/{pool.session_name}/" + ) typer.echo("Press Ctrl+C to stop.") - while True: - await asyncio.sleep(5) + await server.run() From fc5892044a10e5932e581fe9717c234b124a2cd2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:15:32 +0200 Subject: [PATCH 039/121] test: update tests for Server refactoring - test_instance: add check_alive tests (healthy, dead, not-started) - test_pool: replace context manager tests with check_alive tests - test_state: replace context manager tests with write/delete tests - test_server: new file with 5 tests for Server lifecycle and run loop --- tests/cli/test_instance.py | 51 ++++++++- tests/cli/test_pool.py | 56 +++++++--- tests/cli/test_server.py | 159 ++++++++++++++++++++++++++++ tests/cli/test_state.py | 209 ++++++++++--------------------------- 4 files changed, 301 insertions(+), 174 deletions(-) create mode 100644 tests/cli/test_server.py diff --git a/tests/cli/test_instance.py b/tests/cli/test_instance.py index 23c9948d..0d785cc9 100644 --- a/tests/cli/test_instance.py +++ b/tests/cli/test_instance.py @@ -6,7 +6,7 @@ import pytest from balatrobot.config import Config -from balatrobot.instance import BalatroInstance +from balatrobot.instance import BalatroInstance, InstanceDiedError class TestBalatroInstanceInit: @@ -174,3 +174,52 @@ async def mock_start(config, session_dir): # After exit, process should be cleared assert instance._process is None + + +class TestBalatroInstanceCheckAlive: + """Tests for BalatroInstance.check_alive() method.""" + + def test_check_alive_healthy(self): + """No exception when process is running (poll returns None).""" + instance = BalatroInstance(port=14001) + mock_process = MagicMock() + mock_process.poll.return_value = None + instance._process = mock_process + + instance.check_alive() # Should not raise + + def test_check_alive_dead(self): + """Raises InstanceDiedError when process has exited.""" + instance = BalatroInstance(port=14001) + mock_process = MagicMock() + mock_process.poll.return_value = 1 # Exit code 1 + instance._process = mock_process + instance._log_path = MagicMock() + instance._log_path.__str__ = lambda self: "/tmp/test/14001.log" + + with pytest.raises(InstanceDiedError) as exc_info: + instance.check_alive() + assert exc_info.value.port == 14001 + assert "14001" in str(exc_info.value) + + def test_check_alive_dead_with_log_path(self): + """InstanceDiedError includes log_path in message.""" + from pathlib import Path + + instance = BalatroInstance(port=14002) + mock_process = MagicMock() + mock_process.poll.return_value = 0 + instance._process = mock_process + instance._log_path = Path("/tmp/logs/session/14002.log") + + with pytest.raises(InstanceDiedError) as exc_info: + instance.check_alive() + assert exc_info.value.port == 14002 + assert exc_info.value.log_path == "/tmp/logs/session/14002.log" + assert "/tmp/logs/session/14002.log" in str(exc_info.value) + + def test_check_alive_not_started(self): + """No exception when _process is None (not started).""" + instance = BalatroInstance(port=14001) + assert instance._process is None + instance.check_alive() # Should not raise diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py index 635cc968..f41308f8 100644 --- a/tests/cli/test_pool.py +++ b/tests/cli/test_pool.py @@ -6,7 +6,7 @@ import pytest from balatrobot.config import Config -from balatrobot.instance import BalatroInstance +from balatrobot.instance import BalatroInstance, InstanceDiedError from balatrobot.pool import BalatroPool, InstanceInfo # ============================================================================ @@ -234,26 +234,48 @@ async def test_instances_populated_after_start(self, tmp_path): await pool.stop() -class TestBalatroPoolContextManager: - """Tests for BalatroPool async context manager.""" +class TestBalatroPoolCheckAlive: + """Tests for BalatroPool.check_alive() method.""" - @pytest.mark.asyncio - async def test_context_manager(self, tmp_path): - """Pool works as async context manager.""" - config = Config(logs_path=str(tmp_path)) + def test_check_alive_all_healthy(self): + """No exception when all instances are alive.""" + config = Config() + pool = BalatroPool(config, ports=[14001, 14002]) - mock_inst = MagicMock(spec=BalatroInstance) - mock_inst.port = 14001 - mock_inst.log_path = "/tmp/test-logs/14001.log" - mock_inst.start = AsyncMock() - mock_inst.stop = AsyncMock() + mock_inst1 = MagicMock(spec=BalatroInstance) + mock_inst1.check_alive = MagicMock() + mock_inst2 = MagicMock(spec=BalatroInstance) + mock_inst2.check_alive = MagicMock() - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): - async with BalatroPool(config, ports=[14001]) as pool: - assert pool.is_started is True - assert len(pool.instances) == 1 + pool._instances = [mock_inst1, mock_inst2] + pool.check_alive() # Should not raise - assert pool.is_started is False + mock_inst1.check_alive.assert_called_once() + mock_inst2.check_alive.assert_called_once() + + def test_check_alive_one_dead(self): + """Raises InstanceDiedError from first dead instance.""" + config = Config() + pool = BalatroPool(config, ports=[14001, 14002]) + + mock_inst1 = MagicMock(spec=BalatroInstance) + mock_inst1.check_alive = MagicMock() + mock_inst2 = MagicMock(spec=BalatroInstance) + mock_inst2.check_alive = MagicMock( + side_effect=InstanceDiedError(port=14002, log_path="/tmp/14002.log") + ) + + pool._instances = [mock_inst1, mock_inst2] + with pytest.raises(InstanceDiedError) as exc_info: + pool.check_alive() + assert exc_info.value.port == 14002 + + def test_check_alive_empty_pool(self): + """No exception when pool has no instances.""" + config = Config() + pool = BalatroPool(config) + pool._instances = [] + pool.check_alive() # Should not raise class TestBalatroPoolPortAllocation: diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py new file mode 100644 index 00000000..1df93733 --- /dev/null +++ b/tests/cli/test_server.py @@ -0,0 +1,159 @@ +"""Tests for balatrobot.cli.serve.Server class.""" + +import json +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from balatrobot.cli.serve import Server +from balatrobot.config import Config +from balatrobot.instance import InstanceDiedError +from balatrobot.pool import BalatroPool +from balatrobot.state import StateFileBusy + + +class TestServerContextManager: + """Tests for Server async context manager lifecycle.""" + + @pytest.mark.asyncio + async def test_enter_writes_state_exit_deletes(self, tmp_path): + """State file exists inside with block, gone after exit.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ + patch("balatrobot.state.allocate_ports", return_value=[14001]): + async with Server(config, n=1, state_path=state_path) as _server: + assert state_path.exists() + data = json.loads(state_path.read_text()) + assert data["pid"] == os.getpid() + assert len(data["instances"]) == 1 + assert data["instances"][0]["port"] == 14001 + + assert not state_path.exists() + + @pytest.mark.asyncio + async def test_enter_double_start_raises_busy(self, tmp_path): + """StateFileBusy raised if another live state file exists.""" + state_path = tmp_path / "state.json" + + # Write a "live" state file with current PID + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], + } + state_path.write_text(json.dumps(state_data)) + + config = Config(logs_path=str(tmp_path)) + server = Server(config, n=1, state_path=state_path) + + with pytest.raises(StateFileBusy): + await server.__aenter__() + + @pytest.mark.asyncio + async def test_enter_pool_failure_cleans_up(self, tmp_path): + """No state file left if pool.start() fails.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ + patch("balatrobot.state.allocate_ports", return_value=[14001]): + server = Server(config, n=1, state_path=state_path) + with pytest.raises(RuntimeError, match="start failed"): + await server.__aenter__() + + # State file should not exist + assert not state_path.exists() + + @pytest.mark.asyncio + async def test_pool_property(self, tmp_path): + """Server.pool returns the pool after enter.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ + patch("balatrobot.state.allocate_ports", return_value=[14001]): + async with Server(config, n=1, state_path=state_path) as server: + assert server.pool is not None + assert isinstance(server.pool, BalatroPool) + assert server.pool.is_started is True + + +class TestServerRun: + """Tests for Server.run() supervision loop.""" + + @pytest.mark.asyncio + async def test_run_sigterm_exits_cleanly(self, tmp_path): + """run() returns normally when shutdown event is set.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ + patch("balatrobot.state.allocate_ports", return_value=[14001]): + async with Server(config, n=1, state_path=state_path) as server: + # Pre-set the shutdown event so run() exits immediately + server._shutdown.set() + await server.run() # Should return without error + + @pytest.mark.asyncio + async def test_run_child_death_raises(self, tmp_path): + """run() raises InstanceDiedError when child dies, state file cleaned up.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ + patch("balatrobot.state.allocate_ports", return_value=[14001]): + async with Server(config, n=1, state_path=state_path) as server: + # Mock check_alive to raise InstanceDiedError + assert server._pool is not None + server._pool.check_alive = MagicMock( # ty: ignore[invalid-assignment] + side_effect=InstanceDiedError( + port=14001, log_path="/tmp/test-logs/14001.log" + ) + ) + with pytest.raises(InstanceDiedError) as exc_info: + await server.run() + assert exc_info.value.port == 14001 + + # State file should be cleaned up by __aexit__ + assert not state_path.exists() diff --git a/tests/cli/test_state.py b/tests/cli/test_state.py index a2d53600..4417fa62 100644 --- a/tests/cli/test_state.py +++ b/tests/cli/test_state.py @@ -2,12 +2,9 @@ import json import os -from unittest.mock import AsyncMock, MagicMock, patch import pytest -from balatrobot.config import Config -from balatrobot.pool import BalatroPool from balatrobot.state import ( InstanceNotFoundError, StateFile, @@ -308,157 +305,69 @@ def test_resolve_host_port_not_in_instances(self, tmp_path, monkeypatch): # ============================================================================ -# StateFile context manager tests +# StateFile.write / delete tests # ============================================================================ -class TestStateFileContextManager: - """Tests for StateFile as async context manager.""" +class TestStateFileWriteDelete: + """Tests for StateFile.write and StateFile.delete static methods.""" - @pytest.mark.asyncio - async def test_context_manager_writes_state(self, tmp_path): - """StateFile writes state file on enter, deletes on exit.""" - state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) - - mock_inst = MagicMock() - mock_inst.port = 14001 - mock_inst.log_path = "/tmp/test-logs/14001.log" - mock_inst.start = AsyncMock() - mock_inst.stop = AsyncMock() - - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): - pool = BalatroPool(config, ports=[14001]) - sf = StateFile(pool, path=state_path) - async with sf: - assert state_path.exists() - data = json.loads(state_path.read_text()) - assert data["pid"] == os.getpid() - assert len(data["instances"]) == 1 - assert data["instances"][0]["port"] == 14001 - assert data["instances"][0]["log_path"] == "/tmp/test-logs/14001.log" - assert "started_at" in data - - assert not state_path.exists() + def test_write_creates_state_file(self, tmp_path): + """write() creates a valid state file.""" + from balatrobot.pool import InstanceInfo - @pytest.mark.asyncio - async def test_delegates_instances(self, tmp_path): - """StateFile.instances delegates to pool.""" - config = Config(logs_path=str(tmp_path)) - - mock_inst = MagicMock() - mock_inst.port = 14001 - mock_inst.log_path = "/tmp/test-logs/14001.log" - mock_inst.start = AsyncMock() - mock_inst.stop = AsyncMock() - - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): - pool = BalatroPool(config, ports=[14001]) - sf = StateFile(pool, path=tmp_path / "state.json") - async with sf: - assert len(sf.instances) == 1 - assert sf.instances[0].port == 14001 - assert sf.instances[0].log_path == "/tmp/test-logs/14001.log" - - @pytest.mark.asyncio - async def test_path_property(self, tmp_path): - """StateFile.path returns resolved path.""" state_path = tmp_path / "state.json" - config = Config() - pool = BalatroPool(config) - sf = StateFile(pool, path=state_path) - assert sf.path == state_path - - @pytest.mark.asyncio - async def test_double_start_raises_busy(self, tmp_path): - """StateFileBusy raised if another live state file exists.""" - state_path = tmp_path / "state.json" - - # Write a "live" state file with current PID - state_data = { - "pid": os.getpid(), - "started_at": "2026-05-28T12:00:00Z", - "instances": [ - { - "host": "127.0.0.1", - "port": 14001, - "log_path": "/tmp/logs/s/14001.log", - } - ], - } - state_path.write_text(json.dumps(state_data)) + instances = [ + InstanceInfo(host="127.0.0.1", port=14001, log_path="/tmp/a.log"), + InstanceInfo(host="127.0.0.1", port=14002, log_path=None), + ] + StateFile.write(state_path, pid=12345, instances=instances) + + assert state_path.exists() + data = json.loads(state_path.read_text()) + assert data["pid"] == 12345 + assert "started_at" in data + assert len(data["instances"]) == 2 + assert data["instances"][0]["host"] == "127.0.0.1" + assert data["instances"][0]["port"] == 14001 + assert data["instances"][0]["log_path"] == "/tmp/a.log" + assert data["instances"][1]["log_path"] is None + + def test_write_atomic(self, tmp_path): + """write() succeeds (smoke test for atomicity).""" + from balatrobot.pool import InstanceInfo - config = Config(logs_path=str(tmp_path)) - mock_inst = MagicMock() - mock_inst.port = 14001 - mock_inst.log_path = "/tmp/test-logs/14001.log" - mock_inst.start = AsyncMock() - mock_inst.stop = AsyncMock() - - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): - pool = BalatroPool(config, ports=[14001]) - sf = StateFile(pool, path=state_path) - with pytest.raises(StateFileBusy): - async with sf: - pass - - @pytest.mark.asyncio - async def test_stale_state_does_not_raise_busy(self, tmp_path): - """Stale state file is cleaned up and doesn't raise StateFileBusy.""" state_path = tmp_path / "state.json" + instances = [InstanceInfo(host="127.0.0.1", port=14001)] + StateFile.write(state_path, pid=os.getpid(), instances=instances) + assert state_path.exists() - # Write a "stale" state file with dead PID - state_data = { - "pid": 999999999, - "started_at": "2026-05-28T12:00:00Z", - "instances": [ - { - "host": "127.0.0.1", - "port": 14001, - "log_path": "/tmp/logs/s/14001.log", - } - ], - } - state_path.write_text(json.dumps(state_data)) + def test_write_creates_parent_dir(self, tmp_path): + """write() creates parent directories if they don't exist.""" + from balatrobot.pool import InstanceInfo - config = Config(logs_path=str(tmp_path)) - mock_inst = MagicMock() - mock_inst.port = 14001 - mock_inst.log_path = "/tmp/test-logs/14001.log" - mock_inst.start = AsyncMock() - mock_inst.stop = AsyncMock() - - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): - pool = BalatroPool(config, ports=[14001]) - sf = StateFile(pool, path=state_path) - async with sf: - # Should succeed — stale file cleaned up - assert sf.is_started is True - - @pytest.mark.asyncio - async def test_write_failure_stops_pool(self, tmp_path): - """Pool is stopped if state file write fails after pool start.""" - state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + state_path = tmp_path / "nested" / "dir" / "state.json" + instances = [InstanceInfo(host="127.0.0.1", port=14001)] + StateFile.write(state_path, pid=os.getpid(), instances=instances) + assert state_path.exists() - mock_inst = MagicMock() - mock_inst.port = 14001 - mock_inst.log_path = "/tmp/test-logs/14001.log" - mock_inst.start = AsyncMock() - mock_inst.stop = AsyncMock() + def test_delete_removes_file(self, tmp_path): + """delete() removes an existing state file.""" + from balatrobot.pool import InstanceInfo - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): - pool = BalatroPool(config, ports=[14001]) - sf = StateFile(pool, path=state_path) + state_path = tmp_path / "state.json" + instances = [InstanceInfo(host="127.0.0.1", port=14001)] + StateFile.write(state_path, pid=os.getpid(), instances=instances) + assert state_path.exists() - # Make _write_state fail after pool starts - with patch.object(sf, "_write_state", side_effect=OSError("disk full")): - with pytest.raises(OSError, match="disk full"): - async with sf: - pass + StateFile.delete(state_path) + assert not state_path.exists() - # Pool should have been stopped (stop called on mock_inst) - mock_inst.stop.assert_called() + def test_delete_silent_on_missing(self, tmp_path): + """delete() doesn't raise for non-existent path.""" + state_path = tmp_path / "nonexistent.json" + StateFile.delete(state_path) # Should not raise + assert not state_path.exists() # ============================================================================ @@ -469,21 +378,9 @@ async def test_write_failure_stops_pool(self, tmp_path): class TestStateFilePath: """Tests for StateFile default path resolution.""" - def test_default_path_uses_platformdirs(self, tmp_path, monkeypatch): - """Default path uses platformdirs.user_state_dir.""" + def test_default_path_uses_env_var(self, tmp_path, monkeypatch): + """BALATROBOT_STATE_DIR overrides default path.""" monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) - config = Config() - pool = BalatroPool(config) - sf = StateFile(pool, path=tmp_path / "state.json") - assert sf.path == tmp_path / "state.json" + from balatrobot.state import _default_state_path - def test_env_var_overrides_path(self, tmp_path, monkeypatch): - """BALATROBOT_STATE_DIR overrides default path.""" - state_dir = tmp_path / "custom_state" - state_dir.mkdir() - monkeypatch.setenv("BALATROBOT_STATE_DIR", str(state_dir)) - - config = Config() - pool = BalatroPool(config) - sf = StateFile(pool) - assert sf.path == state_dir / "state.json" + assert _default_state_path() == tmp_path / "state.json" From ae88e9d2856ea1c0604be2533c63964c24cdf53e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:46:35 +0200 Subject: [PATCH 040/121] refactor: trim __all__ to public API surface Remove BalatroPool, InstanceInfo, and StateFile from __all__. These are CLI internals, not part of the public bot-author API. Keep APIError, BalatroClient, BalatroInstance, Config, __version__. --- src/balatrobot/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/balatrobot/__init__.py b/src/balatrobot/__init__.py index 1a2654de..4edc3b89 100644 --- a/src/balatrobot/__init__.py +++ b/src/balatrobot/__init__.py @@ -3,17 +3,12 @@ from balatrobot.cli.client import APIError, BalatroClient from balatrobot.config import Config from balatrobot.instance import BalatroInstance -from balatrobot.pool import BalatroPool, InstanceInfo -from balatrobot.state import StateFile __version__ = "1.5.2" __all__ = [ "APIError", "BalatroClient", "BalatroInstance", - "BalatroPool", "Config", - "InstanceInfo", - "StateFile", "__version__", ] From e7cf13de97d2678ed3a3d26fb90b6827677cb8c7 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:46:45 +0200 Subject: [PATCH 041/121] fix(serve): guard signal handler for Windows and clean up on exit Add sys.platform != 'win32' guard around add_signal_handler/ remove_signal_handler. Wrap the supervision loop in try/finally to ensure the signal handler is always removed, even on InstanceDiedError. --- src/balatrobot/cli/serve.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index ad0c831c..9ede1e34 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -3,6 +3,7 @@ import asyncio import os import signal +import sys from pathlib import Path from typing import Annotated @@ -73,14 +74,20 @@ async def run(self) -> None: """ assert self._pool is not None # set by __aenter__ loop = asyncio.get_running_loop() - loop.add_signal_handler(signal.SIGTERM, self._shutdown.set) - - while not self._shutdown.is_set(): - self._pool.check_alive() - try: - await asyncio.wait_for(self._shutdown.wait(), timeout=5) - except asyncio.TimeoutError: - pass + + if sys.platform != "win32": + loop.add_signal_handler(signal.SIGTERM, self._shutdown.set) + + try: + while not self._shutdown.is_set(): + self._pool.check_alive() + try: + await asyncio.wait_for(self._shutdown.wait(), timeout=5) + except asyncio.TimeoutError: + pass + finally: + if sys.platform != "win32": + loop.remove_signal_handler(signal.SIGTERM) def serve( From 7757af4f98dbedc7e6eaee54fd4592d71c5dade9 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:46:54 +0200 Subject: [PATCH 042/121] docs(pool): update docstring to remove context-manager mention Replace 'async context-manager protocol' with check_alive(). The pool no longer has __aenter__/__aexit__. --- src/balatrobot/pool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py index 3599fc8a..ddd06455 100644 --- a/src/balatrobot/pool.py +++ b/src/balatrobot/pool.py @@ -26,8 +26,8 @@ class BalatroPool: """Manages N BalatroInstance instances with port allocation. The pool creates ``n`` instances from a base config, assigning unique - ports to each. It supports ``start()``/``stop()`` as well as the - async context-manager protocol. + ports to each. Use ``start()``/``stop()`` to manage the lifecycle + and ``check_alive()`` to detect child-death. Fail-fast: if any instance fails to start, all already-started instances are stopped and the error is re-raised. From abaaa23bd21c9c5220c4a08d094d7770cfd4a178 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:47:04 +0200 Subject: [PATCH 043/121] test(server): add Windows signal-handler guard test Verify that add_signal_handler is never called on win32 and that the supervision loop still exits cleanly. --- tests/cli/test_server.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py index 1df93733..23ec1d91 100644 --- a/tests/cli/test_server.py +++ b/tests/cli/test_server.py @@ -157,3 +157,32 @@ async def test_run_child_death_raises(self, tmp_path): # State file should be cleaned up by __aexit__ assert not state_path.exists() + + @pytest.mark.asyncio + async def test_run_skips_signal_handler_on_windows(self, tmp_path): + """run() does not register signal handlers on Windows.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ + patch("balatrobot.state.allocate_ports", return_value=[14001]), \ + patch("balatrobot.cli.serve.sys") as mock_sys, \ + patch("balatrobot.cli.serve.asyncio.get_running_loop") as mock_get_loop: + mock_sys.platform = "win32" + mock_loop = MagicMock() + mock_get_loop.return_value = mock_loop + + async with Server(config, n=1, state_path=state_path) as server: + server._shutdown.set() + await server.run() + + # add_signal_handler should never be called on Windows + mock_loop.add_signal_handler.assert_not_called() + mock_loop.remove_signal_handler.assert_not_called() From 1b66787b04614c32d69157759f129715226841e7 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 14:51:42 +0200 Subject: [PATCH 044/121] style: use parenthesized with-statements --- src/balatrobot/cli/list.py | 4 +++- tests/cli/test_server.py | 40 +++++++++++++++++++++++++------------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/balatrobot/cli/list.py b/src/balatrobot/cli/list.py index 4ccc7ead..fd212472 100644 --- a/src/balatrobot/cli/list.py +++ b/src/balatrobot/cli/list.py @@ -30,4 +30,6 @@ def list_cmd( typer.echo(f"Started: {started_at}") typer.echo(f"Instances ({len(instances)}):") for i, inst in enumerate(instances): - typer.echo(f" [{i}] http://{inst['host']}:{inst['port']} log: {inst['log_path']}") + typer.echo( + f" [{i}] http://{inst['host']}:{inst['port']} log: {inst['log_path']}" + ) diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py index 23ec1d91..7263c0e3 100644 --- a/tests/cli/test_server.py +++ b/tests/cli/test_server.py @@ -29,8 +29,10 @@ async def test_enter_writes_state_exit_deletes(self, tmp_path): mock_inst.stop = AsyncMock() mock_inst.check_alive = MagicMock() - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ - patch("balatrobot.state.allocate_ports", return_value=[14001]): + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): async with Server(config, n=1, state_path=state_path) as _server: assert state_path.exists() data = json.loads(state_path.read_text()) @@ -77,8 +79,10 @@ async def test_enter_pool_failure_cleans_up(self, tmp_path): mock_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) mock_inst.stop = AsyncMock() - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ - patch("balatrobot.state.allocate_ports", return_value=[14001]): + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): server = Server(config, n=1, state_path=state_path) with pytest.raises(RuntimeError, match="start failed"): await server.__aenter__() @@ -98,8 +102,10 @@ async def test_pool_property(self, tmp_path): mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ - patch("balatrobot.state.allocate_ports", return_value=[14001]): + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): async with Server(config, n=1, state_path=state_path) as server: assert server.pool is not None assert isinstance(server.pool, BalatroPool) @@ -122,8 +128,10 @@ async def test_run_sigterm_exits_cleanly(self, tmp_path): mock_inst.stop = AsyncMock() mock_inst.check_alive = MagicMock() - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ - patch("balatrobot.state.allocate_ports", return_value=[14001]): + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): async with Server(config, n=1, state_path=state_path) as server: # Pre-set the shutdown event so run() exits immediately server._shutdown.set() @@ -141,8 +149,10 @@ async def test_run_child_death_raises(self, tmp_path): mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ - patch("balatrobot.state.allocate_ports", return_value=[14001]): + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): async with Server(config, n=1, state_path=state_path) as server: # Mock check_alive to raise InstanceDiedError assert server._pool is not None @@ -171,10 +181,12 @@ async def test_run_skips_signal_handler_on_windows(self, tmp_path): mock_inst.stop = AsyncMock() mock_inst.check_alive = MagicMock() - with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), \ - patch("balatrobot.state.allocate_ports", return_value=[14001]), \ - patch("balatrobot.cli.serve.sys") as mock_sys, \ - patch("balatrobot.cli.serve.asyncio.get_running_loop") as mock_get_loop: + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + patch("balatrobot.cli.serve.sys") as mock_sys, + patch("balatrobot.cli.serve.asyncio.get_running_loop") as mock_get_loop, + ): mock_sys.platform = "win32" mock_loop = MagicMock() mock_get_loop.return_value = mock_loop From 5420999aeeb76f8278b2cc0c3b5a1cacea2f2a7e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 15:42:37 +0200 Subject: [PATCH 045/121] feat(cli): add stop command to gracefully shut down server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sends SIGTERM to the server PID from the state file, polls for process death (100ms intervals, 5s timeout). Idempotent — calling twice or on an already-dead process is safe. --- src/balatrobot/cli/__init__.py | 2 + src/balatrobot/cli/stop.py | 54 ++++++++++++ tests/cli/test_serve_cmd.py | 1 + tests/cli/test_stop_cmd.py | 148 +++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 src/balatrobot/cli/stop.py create mode 100644 tests/cli/test_stop_cmd.py diff --git a/src/balatrobot/cli/__init__.py b/src/balatrobot/cli/__init__.py index 6998ccad..09e55cfb 100644 --- a/src/balatrobot/cli/__init__.py +++ b/src/balatrobot/cli/__init__.py @@ -5,6 +5,7 @@ from balatrobot.cli.api import api from balatrobot.cli.list import list_cmd from balatrobot.cli.serve import serve +from balatrobot.cli.stop import stop app = typer.Typer( name="balatrobot", @@ -16,6 +17,7 @@ app.command()(serve) app.command()(api) app.command(name="list")(list_cmd) +app.command()(stop) def main() -> None: diff --git a/src/balatrobot/cli/stop.py b/src/balatrobot/cli/stop.py new file mode 100644 index 00000000..5d412dba --- /dev/null +++ b/src/balatrobot/cli/stop.py @@ -0,0 +1,54 @@ +"""Stop command — stop a running BalatroBot server.""" + +import os +import signal +import time +from typing import Annotated + +import typer + +from balatrobot.state import StateFile + + +def stop() -> None: + """Stop a running BalatroBot server.""" + data = StateFile.read() + + if data is None: + typer.echo("No running instances.") + return + + pid = data.get("pid") + if pid is None: + typer.echo("No running instances.") + return + + # Send SIGTERM + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + # Already dead — treat as success + typer.echo(f"Server stopped (PID {pid}).") + return + except PermissionError: + typer.echo( + f"Permission denied: PID {pid} is owned by another user.", err=True + ) + raise typer.Exit(code=1) + except OSError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) + + # Poll for process to die (100ms intervals, up to 5s) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError, OSError): + # Process is gone + typer.echo(f"Server stopped (PID {pid}).") + return + time.sleep(0.1) + + typer.echo(f"Timed out waiting for PID {pid} to stop.", err=True) + raise typer.Exit(code=1) diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index 950c4c29..f2661fb0 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -95,6 +95,7 @@ def test_main_help(self): assert "serve" in result.output assert "api" in result.output assert "list" in result.output + assert "stop" in result.output def test_no_args_shows_help(self): """Running without args shows help (exit code 2 for multi-command apps).""" diff --git a/tests/cli/test_stop_cmd.py b/tests/cli/test_stop_cmd.py new file mode 100644 index 00000000..cd772279 --- /dev/null +++ b/tests/cli/test_stop_cmd.py @@ -0,0 +1,148 @@ +"""Tests for balatrobot stop command.""" + +import json +import os +import signal +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from balatrobot.cli import app + +runner = CliRunner() + + +class TestStopCommand: + """Test balatrobot stop command.""" + + def test_stop_help(self): + """Stop --help shows usage.""" + result = runner.invoke(app, ["stop", "--help"]) + assert result.exit_code == 0 + assert "stop" in result.output.lower() + + def test_stop_no_state_file(self, tmp_path, monkeypatch): + """Stop shows message when no state file exists.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["stop"]) + assert result.exit_code == 0 + assert "No running instances" in result.output + + def test_stop_dead_pid_in_state_file(self, tmp_path, monkeypatch): + """Dead PID in state file is auto-deleted, shows no instances.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + # Write state file with a PID that definitely doesn't exist + state_path = tmp_path / "state.json" + state_data = { + "pid": 999999999, + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = runner.invoke(app, ["stop"]) + assert result.exit_code == 0 + assert "No running instances" in result.output + # State file should have been cleaned up by StateFile.read() + assert not state_path.exists() + + def test_stop_live_pid_sigterm_succeeds(self, tmp_path, monkeypatch): + """Stop sends SIGTERM and reports success when process dies.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + # First os.kill: StateFile.read() alive check (signal 0) → None + # Second os.kill: stop() SIGTERM → ProcessLookupError (already dead) + with patch("balatrobot.cli.stop.os.kill") as mock_kill: + mock_kill.side_effect = [None, ProcessLookupError()] + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 0 + assert f"Server stopped (PID {os.getpid()})" in result.output + assert mock_kill.call_count == 2 + + def test_stop_live_pid_timeout(self, tmp_path, monkeypatch): + """Stop reports error when process won't die within timeout.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + # os.kill always succeeds — process never dies + # time.monotonic jumps forward to skip the 5s wait + with ( + patch("balatrobot.cli.stop.os.kill", return_value=None), + patch("balatrobot.cli.stop.time.monotonic") as mock_time, + patch("balatrobot.cli.stop.time.sleep"), + ): + # deadline = monotonic() + 5.0 = 5.0 + # First poll: monotonic() returns 1.0 (< 5.0, enter loop) + # After sleep, monotonic() returns 100.0 (> 5.0, exit loop → timeout) + mock_time.side_effect = [0.0, 1.0, 100.0] + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 1 + assert "Timed out" in result.output + + def test_stop_permission_denied(self, tmp_path, monkeypatch): + """Stop handles PermissionError from os.kill gracefully.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + def kill_permission_denied(pid, sig): + """Allow signal-0 alive checks, raise on SIGTERM.""" + if sig == signal.SIGTERM: + raise PermissionError("Not allowed") + return None + + with patch( + "balatrobot.cli.stop.os.kill", side_effect=kill_permission_denied + ): + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 1 + assert "Permission denied" in result.output From ab06c3141e9b102d4ca590a5ab3632b0d5885c2d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 15:42:43 +0200 Subject: [PATCH 046/121] test(server): add SIGTERM handler and clean shutdown tests Verify that Server.run() registers a SIGTERM signal handler on non-Windows platforms and that the full SIGTERM flow triggers clean shutdown (state file deleted, pool stopped). --- tests/cli/test_server.py | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py index 7263c0e3..c9b575ff 100644 --- a/tests/cli/test_server.py +++ b/tests/cli/test_server.py @@ -198,3 +198,66 @@ async def test_run_skips_signal_handler_on_windows(self, tmp_path): # add_signal_handler should never be called on Windows mock_loop.add_signal_handler.assert_not_called() mock_loop.remove_signal_handler.assert_not_called() + + @pytest.mark.asyncio + async def test_run_registers_sigterm_handler(self, tmp_path): + """run() registers a SIGTERM handler on non-Windows.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + patch("balatrobot.cli.serve.asyncio.get_running_loop") as mock_get_loop, + patch("balatrobot.cli.serve.signal.SIGTERM", object()), + ): + mock_loop = MagicMock() + mock_get_loop.return_value = mock_loop + + async with Server(config, n=1, state_path=state_path) as server: + server._shutdown.set() + await server.run() + + # Verify SIGTERM handler was registered and later removed + mock_loop.add_signal_handler.assert_called_once() + mock_loop.remove_signal_handler.assert_called_once() + call_args = mock_loop.add_signal_handler.call_args + assert call_args[0][1] == server._shutdown.set + + @pytest.mark.asyncio + async def test_sigterm_triggers_clean_shutdown(self, tmp_path): + """SIGTERM sets shutdown event → run() exits → __aexit__ cleans up.""" + state_path = tmp_path / "state.json" + config = Config(logs_path=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): + # Verify state file and pool are cleaned up after SIGTERM-like flow + async with Server(config, n=1, state_path=state_path) as server: + assert state_path.exists() + assert server.pool is not None + assert server.pool.is_started + + # Simulate SIGTERM: set the shutdown event + server._shutdown.set() + await server.run() + + # After __aexit__: state file deleted, pool stopped + assert not state_path.exists() + mock_inst.stop.assert_called() From 787ea3282d8d527b7966a725b59f76a995b7e402 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 16:08:13 +0200 Subject: [PATCH 047/121] chore(gitignore): add antigravitycli and review skill to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c0614f16..6f5705b0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ vendors/ runs/*.jsonl logs/ +.antigravitycli +.agents/skills/review From c8490786a780cc08869dabf8e293f794a0444f3b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 16:50:45 +0200 Subject: [PATCH 048/121] refactor: apply code review fixes from branch review - Move InstanceInfo from pool.py to instance.py, change log_path type to Path | None (stringify only at JSON boundary) - Rename _default_state_path to default_state_path (public API) - Fix Server.__aexit__ ordering: stop pool before deleting state file - Document BalatroPool as non-restartable after stop() - Fix stale docstring in test_instance.py - Drop redundant host/port args in api.py else branch --- src/balatrobot/cli/api.py | 2 +- src/balatrobot/cli/serve.py | 6 +++--- src/balatrobot/cli/stop.py | 1 - src/balatrobot/instance.py | 16 +++++++++++++++- src/balatrobot/pool.py | 25 ++++++------------------- src/balatrobot/state.py | 16 ++++++++-------- tests/cli/test_instance.py | 2 +- tests/cli/test_pool.py | 31 ++++++++++++++++--------------- tests/cli/test_state.py | 21 +++++++++++---------- tests/cli/test_stop_cmd.py | 2 +- tests/lua/conftest.py | 3 +-- tests/lua/core/test_server.py | 2 +- 12 files changed, 64 insertions(+), 63 deletions(-) diff --git a/src/balatrobot/cli/api.py b/src/balatrobot/cli/api.py index e82b2177..71ac0e38 100644 --- a/src/balatrobot/cli/api.py +++ b/src/balatrobot/cli/api.py @@ -65,7 +65,7 @@ def api( target_port = port else: try: - info = StateFile.resolve(host=host, port=port, index=index) + info = StateFile.resolve(index=index) target_host = info.host target_port = info.port except Exception as e: diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 9ede1e34..aa7c25ed 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -12,7 +12,7 @@ from balatrobot.config import Config from balatrobot.instance import InstanceDiedError from balatrobot.pool import BalatroPool -from balatrobot.state import StateFile, StateFileBusy, _default_state_path +from balatrobot.state import StateFile, StateFileBusy, default_state_path # Platform choices for validation PLATFORM_CHOICES = ["darwin", "linux", "windows", "native"] @@ -36,7 +36,7 @@ def __init__( ) -> None: self._config = config self._n = n - self._state_path = state_path or _default_state_path() + self._state_path = state_path or default_state_path() self._pool: BalatroPool | None = None self._shutdown = asyncio.Event() @@ -63,9 +63,9 @@ async def __aenter__(self) -> "Server": return self async def __aexit__(self, *args: object) -> None: - StateFile.delete(self._state_path) if self._pool is not None: await self._pool.stop() + StateFile.delete(self._state_path) async def run(self) -> None: """Block until SIGTERM or child death. diff --git a/src/balatrobot/cli/stop.py b/src/balatrobot/cli/stop.py index 5d412dba..f8f20499 100644 --- a/src/balatrobot/cli/stop.py +++ b/src/balatrobot/cli/stop.py @@ -3,7 +3,6 @@ import os import signal import time -from typing import Annotated import typer diff --git a/src/balatrobot/instance.py b/src/balatrobot/instance.py index 0f0d1139..48b61623 100644 --- a/src/balatrobot/instance.py +++ b/src/balatrobot/instance.py @@ -2,7 +2,7 @@ import asyncio import subprocess -from dataclasses import replace +from dataclasses import dataclass, replace from datetime import datetime from pathlib import Path @@ -12,6 +12,20 @@ from balatrobot.platforms import get_launcher from balatrobot.platforms.base import BaseLauncher + +@dataclass(frozen=True) +class InstanceInfo: + """Immutable metadata for a running Balatro instance.""" + + host: str + port: int + log_path: Path | None = None + + @property + def url(self) -> str: + """Full HTTP URL for this instance.""" + return f"http://{self.host}:{self.port}" + HEALTH_TIMEOUT = 30.0 diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py index ddd06455..40ef1f90 100644 --- a/src/balatrobot/pool.py +++ b/src/balatrobot/pool.py @@ -1,25 +1,10 @@ """BalatroPool — manages N BalatroInstance instances.""" import asyncio -from dataclasses import dataclass from datetime import datetime from balatrobot.config import Config -from balatrobot.instance import BalatroInstance - - -@dataclass(frozen=True) -class InstanceInfo: - """Immutable metadata for a running Balatro instance.""" - - host: str - port: int - log_path: str | None = None - - @property - def url(self) -> str: - """Full HTTP URL for this instance.""" - return f"http://{self.host}:{self.port}" +from balatrobot.instance import BalatroInstance, InstanceInfo class BalatroPool: @@ -31,6 +16,9 @@ class BalatroPool: Fail-fast: if any instance fails to start, all already-started instances are stopped and the error is re-raised. + + **Not designed for restart.** Calling ``start()`` again after + ``stop()`` is undefined behaviour. """ def __init__( @@ -75,7 +63,7 @@ async def start(self) -> None: if self._started: raise RuntimeError("Pool already started") - # Allocate ports (lazy import to avoid circular dependency) + # Allocate ports if self._ports is not None: ports = self._ports else: @@ -99,9 +87,8 @@ async def start(self) -> None: ) await inst.start() self._instances.append(inst) - log_path = str(inst.log_path) if inst.log_path is not None else None self._infos.append( - InstanceInfo(host=self._config.host, port=port, log_path=log_path) + InstanceInfo(host=self._config.host, port=port, log_path=inst.log_path) ) except Exception: # Fail-fast: stop all instances that were started diff --git a/src/balatrobot/state.py b/src/balatrobot/state.py index 16989898..b9b63e4b 100644 --- a/src/balatrobot/state.py +++ b/src/balatrobot/state.py @@ -15,7 +15,7 @@ from platformdirs import user_state_dir -from balatrobot.pool import InstanceInfo +from balatrobot.instance import InstanceInfo # --------------------------------------------------------------------------- # Port allocation @@ -89,7 +89,7 @@ def __init__(self, index: int | None = None, total: int | None = None) -> None: _ENV_STATE_DIR = "BALATROBOT_STATE_DIR" -def _default_state_path() -> Path: +def default_state_path() -> Path: """Resolve the default state file path. Uses ``BALATROBOT_STATE_DIR`` env var if set, otherwise falls back @@ -131,7 +131,7 @@ def read(path: Path | None = None) -> dict[str, Any] | None: Args: path: Path to read. Defaults to the platform-default path. """ - state_path = path or _default_state_path() + state_path = path or default_state_path() if not state_path.exists(): return None @@ -178,11 +178,11 @@ def resolve( """ data = StateFile.read(path) if data is None: - raise StateFileNotFound(path or _default_state_path()) + raise StateFileNotFound(path or default_state_path()) instances = data.get("instances", []) if not instances: - raise StateFileNotFound(path or _default_state_path()) + raise StateFileNotFound(path or default_state_path()) # Explicit host+port lookup if host is not None and port is not None: @@ -191,7 +191,7 @@ def resolve( return InstanceInfo( host=inst["host"], port=inst["port"], - log_path=inst["log_path"], + log_path=Path(inst["log_path"]) if inst["log_path"] is not None else None, ) raise InstanceNotFoundError(index=None, total=len(instances)) @@ -202,7 +202,7 @@ def resolve( inst = instances[idx] return InstanceInfo( - host=inst["host"], port=inst["port"], log_path=inst["log_path"] + host=inst["host"], port=inst["port"], log_path=Path(inst["log_path"]) if inst["log_path"] is not None else None ) # -- Write / Delete ---------------------------------------------------- @@ -227,7 +227,7 @@ def write( "pid": pid, "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "instances": [ - {"host": info.host, "port": info.port, "log_path": info.log_path} + {"host": info.host, "port": info.port, "log_path": str(info.log_path) if info.log_path is not None else None} for info in instances ], } diff --git a/tests/cli/test_instance.py b/tests/cli/test_instance.py index 0d785cc9..35266708 100644 --- a/tests/cli/test_instance.py +++ b/tests/cli/test_instance.py @@ -1,4 +1,4 @@ -"""Tests for balatrobot.manager module.""" +"""Tests for balatrobot.instance module.""" import asyncio from unittest.mock import AsyncMock, MagicMock diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py index f41308f8..004e08cb 100644 --- a/tests/cli/test_pool.py +++ b/tests/cli/test_pool.py @@ -1,13 +1,14 @@ """Tests for balatrobot.pool module.""" from dataclasses import FrozenInstanceError +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from balatrobot.config import Config -from balatrobot.instance import BalatroInstance, InstanceDiedError -from balatrobot.pool import BalatroPool, InstanceInfo +from balatrobot.instance import BalatroInstance, InstanceDiedError, InstanceInfo +from balatrobot.pool import BalatroPool # ============================================================================ # InstanceInfo tests @@ -26,8 +27,8 @@ def test_create_with_host_port(self): def test_create_with_log_path(self): """InstanceInfo stores log_path.""" - info = InstanceInfo(host="127.0.0.1", port=12346, log_path="/tmp/test.log") - assert info.log_path == "/tmp/test.log" + info = InstanceInfo(host="127.0.0.1", port=12346, log_path=Path("/tmp/test.log")) + assert info.log_path == Path("/tmp/test.log") def test_url_property(self): """url property returns formatted URL.""" @@ -108,7 +109,7 @@ def mock_instance_factory(config_arg, **kwargs): inst = MagicMock(spec=BalatroInstance) port = kwargs.get("port", 12346) inst.port = port - inst.log_path = f"/tmp/test-logs/{port}.log" + inst.log_path = Path(f"/tmp/test-logs/{port}.log") inst.start = AsyncMock() inst.stop = AsyncMock() created_instances.append(inst) @@ -136,7 +137,7 @@ async def test_stop_concurrent(self, tmp_path): for port in [14001, 14002]: inst = MagicMock(spec=BalatroInstance) inst.port = port - inst.log_path = f"/tmp/test-logs/{port}.log" + inst.log_path = Path(f"/tmp/test-logs/{port}.log") inst.start = AsyncMock() inst.stop = AsyncMock() mock_instances.append(inst) @@ -157,13 +158,13 @@ async def test_start_fail_cleans_up(self, tmp_path): started_inst = MagicMock(spec=BalatroInstance) started_inst.port = 14001 - started_inst.log_path = "/tmp/test-logs/14001.log" + started_inst.log_path = Path("/tmp/test-logs/14001.log") started_inst.start = AsyncMock() started_inst.stop = AsyncMock() failed_inst = MagicMock(spec=BalatroInstance) failed_inst.port = 14002 - failed_inst.log_path = "/tmp/test-logs/14002.log" + failed_inst.log_path = Path("/tmp/test-logs/14002.log") failed_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) failed_inst.stop = AsyncMock() @@ -194,7 +195,7 @@ async def test_start_already_started(self, tmp_path): mock_inst = MagicMock(spec=BalatroInstance) mock_inst.port = 14001 - mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.log_path = Path("/tmp/test-logs/14001.log") mock_inst.start = AsyncMock() mock_inst.stop = AsyncMock() @@ -213,7 +214,7 @@ async def test_instances_populated_after_start(self, tmp_path): for port in [14001, 14002]: inst = MagicMock(spec=BalatroInstance) inst.port = port - inst.log_path = f"/tmp/test-logs/{port}.log" + inst.log_path = Path(f"/tmp/test-logs/{port}.log") inst.start = AsyncMock() inst.stop = AsyncMock() mock_instances.append(inst) @@ -228,8 +229,8 @@ async def test_instances_populated_after_start(self, tmp_path): assert infos[0].port == 14001 assert infos[1].port == 14002 assert infos[0].host == config.host - assert infos[0].log_path == "/tmp/test-logs/14001.log" - assert infos[1].log_path == "/tmp/test-logs/14002.log" + assert infos[0].log_path == Path("/tmp/test-logs/14001.log") + assert infos[1].log_path == Path("/tmp/test-logs/14002.log") await pool.stop() @@ -294,7 +295,7 @@ def mock_instance_factory(config_arg, **kwargs): captured_ports.append(port) inst = MagicMock(spec=BalatroInstance) inst.port = port - inst.log_path = f"/tmp/test-logs/{port}.log" + inst.log_path = Path(f"/tmp/test-logs/{port}.log") inst.start = AsyncMock() inst.stop = AsyncMock() return inst @@ -327,7 +328,7 @@ def mock_instance_factory(config_arg, **kwargs): captured_overrides.append(kwargs) inst = MagicMock(spec=BalatroInstance) inst.port = kwargs.get("port", 12346) - inst.log_path = f"/tmp/test-logs/{kwargs.get('port', 12346)}.log" + inst.log_path = Path(f"/tmp/test-logs/{kwargs.get('port', 12346)}.log") inst.start = AsyncMock() inst.stop = AsyncMock() return inst @@ -358,7 +359,7 @@ def mock_instance_factory(config_arg, **kwargs): captured_session_names.append(session_name) inst = MagicMock(spec=BalatroInstance) inst.port = kwargs.get("port", 12346) - inst.log_path = f"/tmp/test-logs/{kwargs.get('port', 12346)}.log" + inst.log_path = Path(f"/tmp/test-logs/{kwargs.get('port', 12346)}.log") inst.start = AsyncMock() inst.stop = AsyncMock() return inst diff --git a/tests/cli/test_state.py b/tests/cli/test_state.py index 4417fa62..a4b0771f 100644 --- a/tests/cli/test_state.py +++ b/tests/cli/test_state.py @@ -2,6 +2,7 @@ import json import os +from pathlib import Path import pytest @@ -187,7 +188,7 @@ def test_resolve_by_host_port(self, tmp_path, monkeypatch): info = StateFile.resolve(host="127.0.0.1", port=14002) assert info.port == 14002 - assert info.log_path == "/tmp/logs/s/14002.log" + assert info.log_path == Path("/tmp/logs/s/14002.log") def test_resolve_by_index(self, tmp_path, monkeypatch): """Resolves by index.""" @@ -213,7 +214,7 @@ def test_resolve_by_index(self, tmp_path, monkeypatch): info = StateFile.resolve(index=1) assert info.port == 14002 - assert info.log_path == "/tmp/logs/s/14002.log" + assert info.log_path == Path("/tmp/logs/s/14002.log") def test_resolve_no_state_file(self, tmp_path, monkeypatch): """Raises StateFileNotFound when no state file.""" @@ -281,7 +282,7 @@ def test_resolve_default_index_zero(self, tmp_path, monkeypatch): info = StateFile.resolve() assert info.port == 14001 # index=0 by default - assert info.log_path == "/tmp/logs/s/14001.log" + assert info.log_path == Path("/tmp/logs/s/14001.log") def test_resolve_host_port_not_in_instances(self, tmp_path, monkeypatch): """Raises InstanceNotFoundError when host:port not found in instances.""" @@ -314,11 +315,11 @@ class TestStateFileWriteDelete: def test_write_creates_state_file(self, tmp_path): """write() creates a valid state file.""" - from balatrobot.pool import InstanceInfo + from balatrobot.instance import InstanceInfo state_path = tmp_path / "state.json" instances = [ - InstanceInfo(host="127.0.0.1", port=14001, log_path="/tmp/a.log"), + InstanceInfo(host="127.0.0.1", port=14001, log_path=Path("/tmp/a.log")), InstanceInfo(host="127.0.0.1", port=14002, log_path=None), ] StateFile.write(state_path, pid=12345, instances=instances) @@ -335,7 +336,7 @@ def test_write_creates_state_file(self, tmp_path): def test_write_atomic(self, tmp_path): """write() succeeds (smoke test for atomicity).""" - from balatrobot.pool import InstanceInfo + from balatrobot.instance import InstanceInfo state_path = tmp_path / "state.json" instances = [InstanceInfo(host="127.0.0.1", port=14001)] @@ -344,7 +345,7 @@ def test_write_atomic(self, tmp_path): def test_write_creates_parent_dir(self, tmp_path): """write() creates parent directories if they don't exist.""" - from balatrobot.pool import InstanceInfo + from balatrobot.instance import InstanceInfo state_path = tmp_path / "nested" / "dir" / "state.json" instances = [InstanceInfo(host="127.0.0.1", port=14001)] @@ -353,7 +354,7 @@ def test_write_creates_parent_dir(self, tmp_path): def test_delete_removes_file(self, tmp_path): """delete() removes an existing state file.""" - from balatrobot.pool import InstanceInfo + from balatrobot.instance import InstanceInfo state_path = tmp_path / "state.json" instances = [InstanceInfo(host="127.0.0.1", port=14001)] @@ -381,6 +382,6 @@ class TestStateFilePath: def test_default_path_uses_env_var(self, tmp_path, monkeypatch): """BALATROBOT_STATE_DIR overrides default path.""" monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) - from balatrobot.state import _default_state_path + from balatrobot.state import default_state_path - assert _default_state_path() == tmp_path / "state.json" + assert default_state_path() == tmp_path / "state.json" diff --git a/tests/cli/test_stop_cmd.py b/tests/cli/test_stop_cmd.py index cd772279..d91500bf 100644 --- a/tests/cli/test_stop_cmd.py +++ b/tests/cli/test_stop_cmd.py @@ -3,7 +3,7 @@ import json import os import signal -from unittest.mock import MagicMock, patch +from unittest.mock import patch from typer.testing import CliRunner diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 5feb13e1..bbe7c7f0 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -13,8 +13,7 @@ import pytest from balatrobot.config import Config -from balatrobot.instance import BalatroInstance -from balatrobot.pool import InstanceInfo +from balatrobot.instance import BalatroInstance, InstanceInfo # ============================================================================ # Constants diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index 4fa30f11..1c4f309d 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -18,7 +18,7 @@ import httpx import pytest -from balatrobot.pool import InstanceInfo +from balatrobot.instance import InstanceInfo class TestHTTPServerInit: From dcef72de46f62802259d8af418ae190af318bfc1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 17:02:17 +0200 Subject: [PATCH 049/121] style: format long lines and add class spacing --- src/balatrobot/cli/stop.py | 4 +--- src/balatrobot/instance.py | 1 + src/balatrobot/pool.py | 4 +++- src/balatrobot/state.py | 16 +++++++++++++--- tests/cli/test_pool.py | 4 +++- tests/cli/test_stop_cmd.py | 4 +--- 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/balatrobot/cli/stop.py b/src/balatrobot/cli/stop.py index f8f20499..3d971dea 100644 --- a/src/balatrobot/cli/stop.py +++ b/src/balatrobot/cli/stop.py @@ -30,9 +30,7 @@ def stop() -> None: typer.echo(f"Server stopped (PID {pid}).") return except PermissionError: - typer.echo( - f"Permission denied: PID {pid} is owned by another user.", err=True - ) + typer.echo(f"Permission denied: PID {pid} is owned by another user.", err=True) raise typer.Exit(code=1) except OSError as e: typer.echo(str(e), err=True) diff --git a/src/balatrobot/instance.py b/src/balatrobot/instance.py index 48b61623..fddff0e0 100644 --- a/src/balatrobot/instance.py +++ b/src/balatrobot/instance.py @@ -26,6 +26,7 @@ def url(self) -> str: """Full HTTP URL for this instance.""" return f"http://{self.host}:{self.port}" + HEALTH_TIMEOUT = 30.0 diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py index 40ef1f90..ac24706d 100644 --- a/src/balatrobot/pool.py +++ b/src/balatrobot/pool.py @@ -88,7 +88,9 @@ async def start(self) -> None: await inst.start() self._instances.append(inst) self._infos.append( - InstanceInfo(host=self._config.host, port=port, log_path=inst.log_path) + InstanceInfo( + host=self._config.host, port=port, log_path=inst.log_path + ) ) except Exception: # Fail-fast: stop all instances that were started diff --git a/src/balatrobot/state.py b/src/balatrobot/state.py index b9b63e4b..37e87ae1 100644 --- a/src/balatrobot/state.py +++ b/src/balatrobot/state.py @@ -191,7 +191,9 @@ def resolve( return InstanceInfo( host=inst["host"], port=inst["port"], - log_path=Path(inst["log_path"]) if inst["log_path"] is not None else None, + log_path=Path(inst["log_path"]) + if inst["log_path"] is not None + else None, ) raise InstanceNotFoundError(index=None, total=len(instances)) @@ -202,7 +204,9 @@ def resolve( inst = instances[idx] return InstanceInfo( - host=inst["host"], port=inst["port"], log_path=Path(inst["log_path"]) if inst["log_path"] is not None else None + host=inst["host"], + port=inst["port"], + log_path=Path(inst["log_path"]) if inst["log_path"] is not None else None, ) # -- Write / Delete ---------------------------------------------------- @@ -227,7 +231,13 @@ def write( "pid": pid, "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "instances": [ - {"host": info.host, "port": info.port, "log_path": str(info.log_path) if info.log_path is not None else None} + { + "host": info.host, + "port": info.port, + "log_path": str(info.log_path) + if info.log_path is not None + else None, + } for info in instances ], } diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py index 004e08cb..feec40d8 100644 --- a/tests/cli/test_pool.py +++ b/tests/cli/test_pool.py @@ -27,7 +27,9 @@ def test_create_with_host_port(self): def test_create_with_log_path(self): """InstanceInfo stores log_path.""" - info = InstanceInfo(host="127.0.0.1", port=12346, log_path=Path("/tmp/test.log")) + info = InstanceInfo( + host="127.0.0.1", port=12346, log_path=Path("/tmp/test.log") + ) assert info.log_path == Path("/tmp/test.log") def test_url_property(self): diff --git a/tests/cli/test_stop_cmd.py b/tests/cli/test_stop_cmd.py index d91500bf..a814eb83 100644 --- a/tests/cli/test_stop_cmd.py +++ b/tests/cli/test_stop_cmd.py @@ -139,9 +139,7 @@ def kill_permission_denied(pid, sig): raise PermissionError("Not allowed") return None - with patch( - "balatrobot.cli.stop.os.kill", side_effect=kill_permission_denied - ): + with patch("balatrobot.cli.stop.os.kill", side_effect=kill_permission_denied): result = runner.invoke(app, ["stop"]) assert result.exit_code == 1 From 2aed776406642b1f79f06bab3d9173822d605e3d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 29 May 2026 18:38:42 +0200 Subject: [PATCH 050/121] docs(skill): add stop command to balatrobot skill --- .agents/skills/balatrobot/SKILL.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index 6052aaaa..3216bcd7 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -5,7 +5,7 @@ description: Launch Balatro with the BalatroBot mod and interact via the CLI. Us # BalatroBot CLI -Three commands: `serve`, `api`, `list`. Explore any with `--help`. +Four commands: `serve`, `api`, `list`, `stop`. Explore any with `--help`. ## `serve` — start Balatro @@ -25,6 +25,14 @@ All flags have `BALATROBOT_*` env var equivalents (e.g. `BALATROBOT_FAST=1`). Se `serve` auto-allocates ports, prints instance URLs and the session logs directory, then blocks until Ctrl+C. It writes a state file so other commands can discover the running instances. +## `stop` — stop a running server + +```bash +balatrobot stop +``` + +Reads the session state file, sends SIGTERM to the server PID, then polls up to 5 s for it to exit. Cleans up the state file on success. Safe to call when nothing is running (prints "No running instances."). + ## `list` — show running instances ```bash From 7682c64d24d1f9c384dfe98dbb6860143d99b42f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Jun 2026 12:24:05 +0200 Subject: [PATCH 051/121] chore(skills): add git-commit skill Reusable skill for generating conventional commits with auto-staging and logical grouping. --- .agents/skills/git-commit/SKILL.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .agents/skills/git-commit/SKILL.md diff --git a/.agents/skills/git-commit/SKILL.md b/.agents/skills/git-commit/SKILL.md new file mode 100644 index 00000000..9fefb7e9 --- /dev/null +++ b/.agents/skills/git-commit/SKILL.md @@ -0,0 +1,37 @@ +--- +name: git-commit +description: 'Conventional commit creator with auto-staging and message generation.' +license: MIT +allowed-tools: Bash +--- + +# Git Commit + +1. **Context:** `git log -n 5` to match repo style. + +2. **Review:** `git status` and `git diff`. + +3. **Stage & Group:** If many files changed, group them into **multiple logical commits** (`git add `). No secrets. + +4. **Message:** ALL commits should be multiline. (No `!` for breaking changes because we are still in early alpha). Format: + + ```text + (): + + <body> + ``` + + where `<title>` ≤ 72 characters (ideal ≤ 50) and `body` wrap at 72 characters. The best commit messages help someone six months later answer "Why was this change made?". + +5. **Commit:** Execute using heredoc: + + ```bash + git commit -m "$(cat <<'EOF' + <message here> + EOF + )" + ``` + +6. **Iterate:** Repeat steps 3-5 until all logical groups are committed. + +7. **Safety:** No `--force`, `reset --hard`, config changes, or `--no-verify`. From a6045219c2d620a1aba26133ff8d139fa4d7b2ca Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 12:24:28 +0200 Subject: [PATCH 052/121] refactor(lua): overhall logging levels and rename BB_LOGGER to BB_FORMAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote routine operational messages (endpoint actions, state transitions, mode activations) from sendDebugMessage to their appropriate levels: sendInfoMessage for normal flow, sendWarnMessage for recoverable issues, sendErrorMessage for failures. Shorten endpoint lifecycle messages to concise labels: "Init foo()" → "foo()", "Return foo()" → "foo() → ok". Rename utils/logger.lua to utils/format.lua (BB_LOGGER → BB_FORMAT) to reflect that the module provides formatting/serialization helpers, not logging — actual logging uses SMODS send*Message functions. --- src/lua/core/dispatcher.lua | 18 +++++++------- src/lua/core/server.lua | 10 ++++---- src/lua/endpoints/add.lua | 10 +++----- src/lua/endpoints/buy.lua | 6 ++--- src/lua/endpoints/cash_out.lua | 4 +-- src/lua/endpoints/discard.lua | 14 +++++------ src/lua/endpoints/gamestate.lua | 4 +-- src/lua/endpoints/health.lua | 4 +-- src/lua/endpoints/load.lua | 5 ++-- src/lua/endpoints/menu.lua | 4 +-- src/lua/endpoints/next_round.lua | 4 +-- src/lua/endpoints/pack.lua | 23 ++++++++---------- src/lua/endpoints/play.lua | 16 ++++++------ src/lua/endpoints/rearrange.lua | 9 +++---- src/lua/endpoints/reroll.lua | 6 +++-- src/lua/endpoints/save.lua | 5 ++-- src/lua/endpoints/screenshot.lua | 5 ++-- src/lua/endpoints/select.lua | 9 +++---- src/lua/endpoints/sell.lua | 6 ++--- src/lua/endpoints/set.lua | 31 +++++++++++++++++++++--- src/lua/endpoints/skip.lua | 7 +++--- src/lua/endpoints/start.lua | 17 +++++++------ src/lua/endpoints/use.lua | 14 +++++------ src/lua/settings.lua | 6 ++--- src/lua/utils/debugger.lua | 8 +++--- src/lua/utils/{logger.lua => format.lua} | 18 +++++++------- 26 files changed, 142 insertions(+), 121 deletions(-) rename src/lua/utils/{logger.lua => format.lua} (87%) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index c6642a15..d11172d4 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -6,8 +6,8 @@ ---@type Validator local Validator = assert(SMODS.load_file("src/lua/core/validator.lua"))() ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() local socket = require("socket") ---@type table<integer, string>? @@ -98,7 +98,7 @@ function BB_DISPATCHER.load_endpoints(endpoint_files) end loaded_count = loaded_count + 1 end - sendDebugMessage("Loaded " .. loaded_count .. " endpoint(s)", "BB.DISPATCHER") + sendInfoMessage("Loaded " .. loaded_count .. " endpoint(s)", "BB.DISPATCHER") return true end @@ -113,7 +113,7 @@ function BB_DISPATCHER.init(server_module, endpoint_files) sendErrorMessage("Dispatcher initialization failed: " .. err, "BB.DISPATCHER") return false end - sendDebugMessage("Dispatcher initialized successfully", "BB.DISPATCHER") + sendInfoMessage("Dispatcher initialized", "BB.DISPATCHER") return true end @@ -121,7 +121,7 @@ end ---@param error_code string function BB_DISPATCHER.send_error(message, error_code) if not BB_DISPATCHER.Server then - sendDebugMessage("Cannot send error - Server not initialized", "BB.DISPATCHER") + sendErrorMessage("Cannot send error - Server not initialized", "BB.DISPATCHER") return end BB_DISPATCHER.Server.send_response({ @@ -168,7 +168,7 @@ function BB_DISPATCHER.dispatch(request) -- Log incoming request with params local start_time = socket.gettime() - sendDebugMessage(request.method .. BB_LOGGER.serialize_params(params), "BB.REQUEST") + sendDebugMessage(request.method .. BB_FORMAT.serialize_params(params), "BB.REQUEST") -- TIER 2: Schema Validation local valid, err_msg, err_code = Validator.validate(params, endpoint.schema) @@ -213,13 +213,13 @@ function BB_DISPATCHER.dispatch(request) local duration_ms = (socket.gettime() - start_time) * 1000 local is_error = response.message ~= nil if is_error then - sendDebugMessage(string.format("%s ERR (%.0fms)", request.method, duration_ms), "BB.RESPONSE") + sendWarnMessage(string.format("%s ERR (%.0fms)", request.method, duration_ms), "BB.RESPONSE") else - sendDebugMessage(string.format("%s OK (%.0fms)", request.method, duration_ms), "BB.RESPONSE") + sendInfoMessage(string.format("%s OK (%.0fms)", request.method, duration_ms), "BB.RESPONSE") end BB_DISPATCHER.Server.send_response(response) else - sendDebugMessage("Cannot send response - Server not initialized", "BB.DISPATCHER") + sendErrorMessage("Cannot send response - Server not initialized", "BB.DISPATCHER") end end local exec_success, exec_error = pcall(function() diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index cbdeb292..7d164896 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -124,13 +124,13 @@ function BB_SERVER.init() if spec_file then BB_SERVER.openrpc_spec = spec_file:read("*a") spec_file:close() - sendDebugMessage("Loaded OpenRPC spec from " .. spec_path, "BB.SERVER") + sendInfoMessage("Loaded OpenRPC spec from " .. spec_path, "BB.SERVER") else sendWarnMessage("OpenRPC spec not found at " .. spec_path, "BB.SERVER") BB_SERVER.openrpc_spec = '{"error": "OpenRPC spec not found"}' end - sendDebugMessage("HTTP server listening on http://" .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") + sendInfoMessage("HTTP server listening on http://" .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") return true end @@ -250,7 +250,7 @@ local function send_raw(response_str) local _, err = BB_SERVER.client_socket:send(response_str) if err then - sendDebugMessage("Failed to send response: " .. err, "BB.SERVER") + sendErrorMessage("Failed to send response: " .. err, "BB.SERVER") return false end return true @@ -412,7 +412,7 @@ function BB_SERVER.send_response(response) local success, json_str = pcall(json.encode, wrapped) if not success then - sendDebugMessage("Failed to encode response: " .. tostring(json_str), "BB.SERVER") + sendErrorMessage("Failed to encode response: " .. tostring(json_str), "BB.SERVER") return false end @@ -466,6 +466,6 @@ function BB_SERVER.close() if BB_SERVER.server_socket then BB_SERVER.server_socket:close() BB_SERVER.server_socket = nil - sendDebugMessage("Server closed", "BB.SERVER") + sendInfoMessage("Server closed", "BB.SERVER") end end diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 05b4f790..5c5fe390 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -166,7 +166,7 @@ return { ---@param args Request.Endpoint.Add.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init add()", "BB.ENDPOINTS") + sendDebugMessage("add()", "BB.ENDPOINTS") -- Detect card type local card_type = detect_card_type(args.key) @@ -408,6 +408,8 @@ return { end end + sendInfoMessage(string.format("Adding %s '%s'", card_type, args.key), "BB.ENDPOINTS") + -- Track initial state for verification local initial_joker_count = G.jokers and G.jokers.config and G.jokers.config.card_count or 0 local initial_consumable_count = G.consumeables and G.consumeables.config and G.consumeables.config.card_count or 0 @@ -415,8 +417,6 @@ return { local initial_hand_count = G.hand and G.hand.config and G.hand.config.card_count or 0 local initial_pack_count = G.shop_booster and G.shop_booster.config and G.shop_booster.config.card_count or 0 - sendDebugMessage("Initial voucher count: " .. initial_voucher_count, "BB.ENDPOINTS") - -- Call SMODS function with error handling local success, result @@ -444,8 +444,6 @@ return { result.ability.perish_tally = args.perishable end - sendDebugMessage("SMODS.add_card called for: " .. args.key .. " (" .. card_type .. ")", "BB.ENDPOINTS") - -- Wait for card addition to complete with event-based verification G.E_MANAGER:add_event(Event({ trigger = "condition", @@ -484,7 +482,7 @@ return { -- All conditions must be met if added and state_stable and valid_state then - sendDebugMessage("Card added successfully: " .. args.key, "BB.ENDPOINTS") + sendDebugMessage("add() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 0e615138..01300417 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -43,7 +43,7 @@ return { ---@param args Request.Endpoint.Buy.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init buy()", "BB.ENDPOINTS") + sendDebugMessage("buy()", "BB.ENDPOINTS") local gamestate = BB_GAMESTATE.get_gamestate() local area local pos @@ -196,7 +196,7 @@ return { -- Log what we're buying local item_name = card.name or (card.ability and card.ability.name) or card.label or "Unknown" local item_type = args.voucher and "Voucher" or args.pack and "Booster" or card.set or "item" - sendDebugMessage(string.format("Buying %s '%s' for $%d", item_type, item_name, card.cost.buy), "BB.ENDPOINTS") + sendInfoMessage(string.format("Buying %s '%s' for $%d", item_type, item_name, card.cost.buy), "BB.ENDPOINTS") -- Use appropriate function: use_card for vouchers, buy_from_shop for others if args.voucher or args.pack then @@ -270,7 +270,7 @@ return { end if done then - sendDebugMessage("Return buy()", "BB.ENDPOINTS") + sendDebugMessage("buy() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index 03e7ee64..c5fed02c 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.CashOut.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init cash_out()", "BB.ENDPOINTS") + sendDebugMessage("cash_out()", "BB.ENDPOINTS") G.FUNCS.cash_out({ config = {} }) local num_items = function(area) @@ -48,7 +48,7 @@ return { if G.STATE == G.STATES.SHOP and G.STATE_COMPLETE then done = num_items(G.shop_booster) > 0 or num_items(G.shop_jokers) > 0 or num_items(G.shop_vouchers) > 0 if done then - sendDebugMessage("Return cash_out() - reached SHOP state", "BB.ENDPOINTS") + sendDebugMessage("cash_out() → SHOP", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return done end diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index cd40854a..a1ab2363 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/discard.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Discard Endpoint Params @@ -35,7 +35,7 @@ return { ---@param args Request.Endpoint.Discard.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init discard()", "BB.ENDPOINTS") + sendDebugMessage("discard()", "BB.ENDPOINTS") if #args.cards == 0 then send_response({ message = "Must provide at least one card to discard", @@ -80,10 +80,10 @@ return { end -- Log the cards being discarded - local card_str = BB_LOGGER.format_playing_cards(G.hand.cards, args.cards) + local card_str = BB_FORMAT.format_playing_cards(G.hand.cards, args.cards) local remaining = G.GAME.current_round.discards_left - 1 - sendDebugMessage( - string.format("Discarding %d cards: %s (%d discards left)", #args.cards, card_str, remaining), + sendInfoMessage( + string.format("Discarding %d cards: %s (%d left)", #args.cards, card_str, remaining), "BB.ENDPOINTS" ) @@ -107,7 +107,7 @@ return { end if draw_to_hand and G.buttons and G.STATE == G.STATES.SELECTING_HAND then - sendDebugMessage("Return discard()", "BB.ENDPOINTS") + sendDebugMessage("discard() → SELECTING_HAND", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true diff --git a/src/lua/endpoints/gamestate.lua b/src/lua/endpoints/gamestate.lua index 7d88100c..2254f495 100644 --- a/src/lua/endpoints/gamestate.lua +++ b/src/lua/endpoints/gamestate.lua @@ -24,9 +24,9 @@ return { ---@param _ Request.Endpoint.Gamestate.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init gamestate()", "BB.ENDPOINTS") + sendDebugMessage("gamestate()", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() - sendDebugMessage("Return gamestate()", "BB.ENDPOINTS") + sendDebugMessage("gamestate() → ok", "BB.ENDPOINTS") send_response(state_data) end, } diff --git a/src/lua/endpoints/health.lua b/src/lua/endpoints/health.lua index f43b4129..07c7032e 100644 --- a/src/lua/endpoints/health.lua +++ b/src/lua/endpoints/health.lua @@ -24,8 +24,8 @@ return { ---@param _ Request.Endpoint.Health.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init health()", "BB.ENDPOINTS") - sendDebugMessage("Return health()", "BB.ENDPOINTS") + sendDebugMessage("health()", "BB.ENDPOINTS") + sendDebugMessage("health() → ok", "BB.ENDPOINTS") send_response({ status = "ok", }) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index e9276a5c..437477b0 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -37,10 +37,11 @@ return { ---@param args Request.Endpoint.Load.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init load()", "BB.ENDPOINTS") + sendDebugMessage("load()", "BB.ENDPOINTS") local path = args.path -- Read file using nativefs + sendInfoMessage("Loading from " .. path, "BB.ENDPOINTS") -- NOTE: We intentionally skip nativefs.getInfo() and go straight to -- nativefs.read(). On Proton/Wine, getInfo() uses PHYSFS_mount which -- cannot resolve Linux absolute paths, but read() goes through fopen() @@ -153,7 +154,7 @@ return { end if done then - sendDebugMessage("Return load() - loaded from " .. path, "BB.ENDPOINTS") + sendDebugMessage("load() → ok", "BB.ENDPOINTS") send_response({ success = true, path = path, diff --git a/src/lua/endpoints/menu.lua b/src/lua/endpoints/menu.lua index daade393..3bc40a2f 100644 --- a/src/lua/endpoints/menu.lua +++ b/src/lua/endpoints/menu.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.Menu.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init menu()", "BB.ENDPOINTS") + sendDebugMessage("menu()", "BB.ENDPOINTS") if G.STATE ~= G.STATES.MENU then G.FUNCS.go_to_menu({}) end @@ -38,7 +38,7 @@ return { local done = G.STATE == G.STATES.MENU and G.MAIN_MENU_UI if done then - sendDebugMessage("Return menu()", "BB.ENDPOINTS") + sendDebugMessage("menu() → MENU", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/next_round.lua b/src/lua/endpoints/next_round.lua index a310bbed..d418ebf6 100644 --- a/src/lua/endpoints/next_round.lua +++ b/src/lua/endpoints/next_round.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.NextRound.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init next_round()", "BB.ENDPOINTS") + sendDebugMessage("next_round()", "BB.ENDPOINTS") G.FUNCS.toggle_shop({}) -- Wait for BLIND_SELECT state after leaving shop @@ -51,7 +51,7 @@ return { return false end - sendDebugMessage("Return next_round() - reached BLIND_SELECT state", "BB.ENDPOINTS") + sendDebugMessage("next_round() → BLIND_SELECT", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end, diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index 2e9a338f..5a99ebf6 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/pack.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Pack Select Endpoint Params @@ -83,7 +83,7 @@ return { ---@param args Request.Endpoint.Pack.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init pack()", "BB.ENDPOINTS") + sendDebugMessage("pack()", "BB.ENDPOINTS") -- Validate that exactly one of card or skip is provided local set = 0 @@ -226,13 +226,10 @@ return { local card_name = card.ability and card.ability.name or "Unknown" local card_set = card.ability and card.ability.set or card.set or "card" if args.targets and #args.targets > 0 then - local targets = BB_LOGGER.format_playing_cards(G.hand.cards, args.targets) - sendDebugMessage( - string.format("Pack: selecting %s '%s' targeting: %s", card_set, card_name, targets), - "BB.ENDPOINTS" - ) + local targets = BB_FORMAT.format_playing_cards(G.hand.cards, args.targets) + sendInfoMessage(string.format("Selecting %s '%s' on: %s", card_set, card_name, targets), "BB.ENDPOINTS") else - sendDebugMessage(string.format("Pack: selecting %s '%s'", card_set, card_name), "BB.ENDPOINTS") + sendInfoMessage(string.format("Selecting %s '%s'", card_set, card_name), "BB.ENDPOINTS") end -- Select the card by calling use_card @@ -260,7 +257,7 @@ return { and G.STATE == G.STATES.SMODS_BOOSTER_OPENED if pack_stable then - sendDebugMessage("Return pack() after selection (more choices remain)", "BB.ENDPOINTS") + sendDebugMessage("pack() → selected (more choices)", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end @@ -270,7 +267,7 @@ return { local back_to_shop = G.STATE == G.STATES.SHOP if pack_closed and back_to_shop then - sendDebugMessage("Return pack() after selection", "BB.ENDPOINTS") + sendDebugMessage("pack() → selected", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end @@ -285,7 +282,7 @@ return { -- Handle skip if args.skip then 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") + sendInfoMessage(string.format("Skipping pack (%d remaining)", pack_count), "BB.ENDPOINTS") G.FUNCS.skip_booster({}) -- Wait for pack to close and return to shop @@ -297,7 +294,7 @@ return { local back_to_shop = G.STATE == G.STATES.SHOP if pack_closed and back_to_shop then - sendDebugMessage("Return pack() after skip", "BB.ENDPOINTS") + sendDebugMessage("pack() → skipped", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index b450039a..5c738b60 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/play.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Play Endpoint Params @@ -35,7 +35,7 @@ return { ---@param args Request.Endpoint.Play.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init play()", "BB.ENDPOINTS") + sendDebugMessage("play()", "BB.ENDPOINTS") if #args.cards == 0 then send_response({ message = "Must provide at least one card to play", @@ -72,8 +72,8 @@ return { end -- Log the cards being played - local card_str = BB_LOGGER.format_playing_cards(G.hand.cards, args.cards) - sendDebugMessage(string.format("Playing %d cards: %s", #args.cards, card_str), "BB.ENDPOINTS") + local card_str = BB_FORMAT.format_playing_cards(G.hand.cards, args.cards) + sendInfoMessage(string.format("Playing %d cards: %s", #args.cards, card_str), "BB.ENDPOINTS") ---@diagnostic disable-next-line: undefined-field local play_button = UIBox:get_UIE_by_ID("play_button", G.buttons.UIRoot) @@ -123,7 +123,7 @@ return { -- Game is won if G.GAME.won then - sendDebugMessage("Return play() - won", "BB.ENDPOINTS") + sendDebugMessage("play() → won", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true @@ -145,14 +145,14 @@ return { -- Both first and last scoring rows must be present if has_blind1 and has_cash_out_button then local state_data = BB_GAMESTATE.get_gamestate() - sendDebugMessage("Return play() - cash out", "BB.ENDPOINTS") + sendDebugMessage("play() → cash_out", "BB.ENDPOINTS") send_response(state_data) return true end end if draw_to_hand and hand_played and G.buttons and G.STATE == G.STATES.SELECTING_HAND then - sendDebugMessage("Return play() - same round", "BB.ENDPOINTS") + sendDebugMessage("play() → continue", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index e8ec143f..195448bc 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -46,7 +46,7 @@ return { ---@param args Request.Endpoint.Rearrange.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init rearrange()", "BB.ENDPOINTS") + sendDebugMessage("rearrange()", "BB.ENDPOINTS") -- Validate exactly one parameter is provided local param_count = (args.hand and 1 or 0) + (args.jokers and 1 or 0) + (args.consumables and 1 or 0) if param_count == 0 then @@ -132,10 +132,7 @@ return { -- Log what we're rearranging local order_str = "[" .. table.concat(indices, ",") .. "]" - sendDebugMessage( - string.format("Rearranging %s (%d cards): %s", type_name, #source_array, order_str), - "BB.ENDPOINTS" - ) + sendInfoMessage(string.format("Rearranging %s (%d cards): %s", type_name, #source_array, order_str), "BB.ENDPOINTS") -- Validate permutation: correct length, no duplicates, all indices present -- Check length matches @@ -226,7 +223,7 @@ return { end if done then - sendDebugMessage("Return rearrange()", "BB.ENDPOINTS") + sendDebugMessage("rearrange() → ok", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua index 3759789e..5596d3a0 100644 --- a/src/lua/endpoints/reroll.lua +++ b/src/lua/endpoints/reroll.lua @@ -24,6 +24,8 @@ return { ---@param _ Request.Endpoint.Reroll.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) + sendDebugMessage("reroll()", "BB.ENDPOINTS") + -- Check affordability (accounting for Credit Card joker via bankrupt_at) local reroll_cost = G.GAME.current_round and G.GAME.current_round.reroll_cost or 0 local available_money = G.GAME.dollars - G.GAME.bankrupt_at @@ -37,7 +39,7 @@ return { end -- Log reroll with cost and money - sendDebugMessage(string.format("Rerolling shop (cost=$%d, money=$%d)", reroll_cost, G.GAME.dollars), "BB.ENDPOINTS") + sendInfoMessage(string.format("Rerolling shop ($%d, money=$%d)", reroll_cost, G.GAME.dollars), "BB.ENDPOINTS") G.FUNCS.reroll_shop(nil) -- Wait for shop state to confirm reroll completed @@ -47,7 +49,7 @@ return { func = function() local done = G.STATE == G.STATES.SHOP if done then - sendDebugMessage(string.format("Return reroll() money=$%d", G.GAME.dollars), "BB.ENDPOINTS") + sendDebugMessage("reroll() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) end return done diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 49ac50f0..7c6ef004 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -53,7 +53,7 @@ return { ---@param args Request.Endpoint.Save.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init save()", "BB.ENDPOINTS") + sendDebugMessage("save()", "BB.ENDPOINTS") local path = args.path -- Validate we're in a run @@ -66,6 +66,7 @@ return { end -- Call save_run() and use compress_and_save + sendInfoMessage("Saving to " .. path, "BB.ENDPOINTS") save_run() ---@diagnostic disable-line: undefined-global local temp_filename = "balatrobot_temp_save_" .. BB_SETTINGS.port .. ".jkr" @@ -97,7 +98,7 @@ return { -- Clean up love.filesystem.remove(temp_filename) - sendDebugMessage("Return save() - saved to " .. path, "BB.ENDPOINTS") + sendDebugMessage("save() → ok", "BB.ENDPOINTS") send_response({ success = true, path = path, diff --git a/src/lua/endpoints/screenshot.lua b/src/lua/endpoints/screenshot.lua index 8be47165..a579a796 100644 --- a/src/lua/endpoints/screenshot.lua +++ b/src/lua/endpoints/screenshot.lua @@ -37,7 +37,7 @@ return { ---@param args Request.Endpoint.Screenshot.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init screenshot()", "BB.ENDPOINTS") + sendDebugMessage("screenshot()", "BB.ENDPOINTS") local path = args.path love.graphics.captureScreenshot(function(imagedata) @@ -56,6 +56,7 @@ return { local png_data = filedata:getString() -- Write to target path using nativefs + sendInfoMessage("Screenshot → " .. path, "BB.ENDPOINTS") local write_success = nativefs.write(path, png_data) if not write_success then send_response({ @@ -65,7 +66,7 @@ return { return end - sendDebugMessage("Return screenshot() - saved to " .. path, "BB.ENDPOINTS") + sendDebugMessage("screenshot() → ok", "BB.ENDPOINTS") send_response({ success = true, path = path, diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua index b250220f..9c0f193e 100644 --- a/src/lua/endpoints/select.lua +++ b/src/lua/endpoints/select.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.Select.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init select()", "BB.ENDPOINTS") + sendDebugMessage("select()", "BB.ENDPOINTS") -- Get current blind and its UI element local current_blind = G.GAME.blind_on_deck assert(current_blind ~= nil, "select() called with no blind on deck") @@ -37,10 +37,7 @@ return { local blind_info = BB_GAMESTATE.get_blinds_info()[string.lower(current_blind)] local blind_name = blind_info and blind_info.name or current_blind local chips = blind_info and blind_info.chips or "?" - sendDebugMessage( - string.format("Selecting %s (%s), chips required: %s", current_blind, blind_name, tostring(chips)), - "BB.ENDPOINTS" - ) + sendInfoMessage(string.format("Selecting %s blind (%s chips)", current_blind, tostring(chips)), "BB.ENDPOINTS") -- Execute blind selection G.FUNCS.select_blind(select_button) @@ -52,7 +49,7 @@ return { func = function() local done = G.STATE == G.STATES.SELECTING_HAND and G.hand ~= nil if done then - sendDebugMessage("Return select()", "BB.ENDPOINTS") + sendDebugMessage("select() → SELECTING_HAND", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 58593f1c..5d1a6f7b 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -37,7 +37,7 @@ return { ---@param args Request.Endpoint.Sell.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init sell()", "BB.ENDPOINTS") + sendDebugMessage("sell()", "BB.ENDPOINTS") -- Validate exactly one parameter is provided local param_count = (args.joker and 1 or 0) + (args.consumable and 1 or 0) @@ -120,7 +120,7 @@ return { -- Log what we're selling local item_name = card.ability and card.ability.name or "Unknown" - sendDebugMessage(string.format("Selling %s '%s' for $%d", sell_type, item_name, card.sell_cost), "BB.ENDPOINTS") + sendInfoMessage(string.format("Selling %s '%s' for $%d", sell_type, item_name, card.sell_cost), "BB.ENDPOINTS") -- Create mock UI element for G.FUNCS.sell_card local mock_element = { @@ -168,7 +168,7 @@ return { -- All conditions must be met if count_decreased and money_increased and card_gone and state_stable and valid_state then - sendDebugMessage("Return sell()", "BB.ENDPOINTS") + sendDebugMessage("sell() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index 5885d455..d2bfc2b8 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -67,7 +67,32 @@ return { ---@param args Request.Endpoint.Set.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init set()", "BB.ENDPOINTS") + sendDebugMessage("set()", "BB.ENDPOINTS") + + -- Build fields string for logging + local fields = {} + if args.money ~= nil then + table.insert(fields, "money=" .. args.money) + end + if args.chips ~= nil then + table.insert(fields, "chips=" .. args.chips) + end + if args.ante ~= nil then + table.insert(fields, "ante=" .. args.ante) + end + if args.round ~= nil then + table.insert(fields, "round=" .. args.round) + end + if args.hands ~= nil then + table.insert(fields, "hands=" .. args.hands) + end + if args.discards ~= nil then + table.insert(fields, "discards=" .. args.discards) + end + if args.shop ~= nil then + table.insert(fields, "shop") + end + sendInfoMessage("Setting " .. table.concat(fields, ", "), "BB.ENDPOINTS") -- Validate we're in a run if G.STAGE and G.STAGE ~= G.STAGES.RUN then @@ -197,14 +222,14 @@ return { local done_packs = G.shop_booster and G.shop_booster.config and G.shop_booster.config.card_count > 0 local done_jokers = G.shop_jokers and G.shop_jokers.config and G.shop_jokers.config.card_count > 0 if done_vouchers or done_packs or done_jokers then - sendDebugMessage("Return set()", "BB.ENDPOINTS") + sendDebugMessage("set() → ok", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true end return false else - sendDebugMessage("Return set()", "BB.ENDPOINTS") + sendDebugMessage("set() → ok", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 5fea1c77..cac3fee7 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.Skip.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init skip()", "BB.ENDPOINTS") + sendDebugMessage("skip()", "BB.ENDPOINTS") -- Get the current blind on deck (similar to select endpoint) local current_blind = G.GAME.blind_on_deck @@ -34,7 +34,7 @@ return { assert(blind ~= nil, "skip() blind not found: " .. current_blind) if blind.type == "BOSS" then - sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS") + sendWarnMessage("Cannot skip Boss blind", "BB.ENDPOINTS") send_response({ message = "Cannot skip Boss blind. Use `select` to select and play the boss blind.", name = BB_ERROR_NAMES.NOT_ALLOWED, @@ -51,6 +51,7 @@ return { assert(skip_button ~= nil, "skip() skip button not found: " .. current_blind) -- Execute blind skip + sendInfoMessage("Skipping " .. current_blind_key .. " blind", "BB.ENDPOINTS") G.FUNCS.skip_blind(skip_button) -- Wait for the skip to complete @@ -67,7 +68,7 @@ return { and blinds[current_blind_key].status == "SKIPPED" ) if done then - sendDebugMessage("Return skip()", "BB.ENDPOINTS") + sendDebugMessage("skip() → BLIND_SELECT", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 5bcefa62..13883ad4 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -76,12 +76,12 @@ return { ---@param args Request.Endpoint.Start.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init start()", "BB.ENDPOINTS") + sendDebugMessage("start()", "BB.ENDPOINTS") -- Validate and map stake enum local stake_number = STAKE_ENUM_TO_NUMBER[args.stake] if not stake_number then - sendDebugMessage("start() called with invalid stake enum: " .. tostring(args.stake), "BB.ENDPOINTS") + sendWarnMessage("Invalid stake enum: " .. tostring(args.stake), "BB.ENDPOINTS") send_response({ message = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " .. tostring(args.stake), @@ -93,7 +93,7 @@ return { -- Validate and map deck enum local deck_name = DECK_ENUM_TO_NAME[args.deck] if not deck_name then - sendDebugMessage("start() called with invalid deck enum: " .. tostring(args.deck), "BB.ENDPOINTS") + sendWarnMessage("Invalid deck enum: " .. tostring(args.deck), "BB.ENDPOINTS") send_response({ message = "Invalid deck enum. Must be one of: RED, BLUE, YELLOW, GREEN, BLACK, MAGIC, NEBULA, GHOST, ABANDONED, CHECKERED, ZODIAC, PAINTED, ANAGLYPH, PLASMA, ERRATIC. Got: " .. tostring(args.deck), @@ -111,7 +111,6 @@ return { if G.P_CENTER_POOLS and G.P_CENTER_POOLS.Back then for _, deck_data in pairs(G.P_CENTER_POOLS.Back) do if deck_data.name == deck_name then - sendDebugMessage("Setting deck to: " .. deck_data.name .. " (from enum: " .. args.deck .. ")", "BB.ENDPOINTS") G.GAME.selected_back:change_to(deck_data) G.GAME.viewed_back:change_to(deck_data) deck_found = true @@ -121,7 +120,7 @@ return { end if not deck_found then - sendDebugMessage("start() deck not found in game data: " .. deck_name, "BB.ENDPOINTS") + sendWarnMessage("Deck not found: " .. deck_name, "BB.ENDPOINTS") send_response({ message = "Deck not found in game data: " .. deck_name, name = BB_ERROR_NAMES.INTERNAL_ERROR, @@ -135,8 +134,10 @@ return { run_params.seed = args.seed end - sendDebugMessage( - "Starting run with stake=" + sendInfoMessage( + "Starting run: " + .. deck_name + .. ", stake=" .. tostring(stake_number) .. " (" .. args.stake @@ -158,7 +159,7 @@ return { and G.blind_select_opts["small"]:get_UIE_by_ID("tag_Small") ~= nil ) if done then - sendDebugMessage("Return start()", "BB.ENDPOINTS") + sendDebugMessage("start() → BLIND_SELECT", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index dedba80c..d8ba1352 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/use.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Use Endpoint Params @@ -41,7 +41,7 @@ return { ---@param args Request.Endpoint.Use.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init use()", "BB.ENDPOINTS") + sendDebugMessage("use()", "BB.ENDPOINTS") -- Step 1: Consumable Index Validation if args.consumable < 0 or args.consumable >= #G.consumeables.cards then @@ -160,10 +160,10 @@ return { -- Log what we're using with target cards local cons_name = consumable_card.ability.name if args.cards and #args.cards > 0 then - local targets = BB_LOGGER.format_playing_cards(G.hand.cards, args.cards) - sendDebugMessage(string.format("Using '%s' on: %s", cons_name, targets), "BB.ENDPOINTS") + local targets = BB_FORMAT.format_playing_cards(G.hand.cards, args.cards) + sendInfoMessage(string.format("Using '%s' on: %s", cons_name, targets), "BB.ENDPOINTS") else - sendDebugMessage(string.format("Using '%s' (no targets)", cons_name), "BB.ENDPOINTS") + sendInfoMessage(string.format("Using '%s'", cons_name), "BB.ENDPOINTS") end -- Step 7: Game-Level Validation (e.g. try to use Familiar Spectral when G.hand is not available) @@ -211,7 +211,7 @@ return { local no_stop_use = not (G.GAME.STOP_USE and G.GAME.STOP_USE > 0) if state_restored and controller_unlocked and no_stop_use then - sendDebugMessage("Return use()", "BB.ENDPOINTS") + sendDebugMessage("use() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/settings.lua b/src/lua/settings.lua index e92043ca..9ea0ba8e 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -185,7 +185,7 @@ local function configure_headless() end end - sendDebugMessage("Headless mode enabled", "BB.SETTINGS") + sendInfoMessage("Headless mode enabled", "BB.SETTINGS") end --- Configures render-on-API mode where frames are only rendered when BB_RENDER is true @@ -217,7 +217,7 @@ local function configure_render_on_api() end end - sendDebugMessage("Render on API mode enabled", "BB.SETTINGS") + sendInfoMessage("Render on API mode enabled", "BB.SETTINGS") end --- Configures fast mode with unlimited FPS, 10x game speed, and 60 FPS animations @@ -239,7 +239,7 @@ local function configure_no_shaders() love.graphics.setShader = function() return love_graphics_setShader() end - sendDebugMessage("Disabled all shaders", "BB.SETTINGS") + sendInfoMessage("Disabled all shaders", "BB.SETTINGS") end --- Enables audio by setting volume levels and enabling sound thread diff --git a/src/lua/utils/debugger.lua b/src/lua/utils/debugger.lua index 186bcb8b..f5bd6f01 100644 --- a/src/lua/utils/debugger.lua +++ b/src/lua/utils/debugger.lua @@ -9,7 +9,7 @@ table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/echo.lua") table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/state.lua") table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/error.lua") table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/validation.lua") -sendDebugMessage("Loading test endpoints", "BB.BALATROBOT") +sendInfoMessage("Loading test endpoints", "BB.BALATROBOT") -- Helper function to format response as pretty-printed table local function format_response(response, depth, indent) @@ -121,16 +121,16 @@ BB_DEBUG = { BB_DEBUG.setup = function() local success, dpAPI = pcall(require, "debugplus.api") if not success or not dpAPI then - sendDebugMessage("DebugPlus API not found", "BB.DEBUGGER") + sendWarnMessage("DebugPlus API not found", "BB.DEBUGGER") return end if not dpAPI.isVersionCompatible(1) then - sendDebugMessage("DebugPlus API version is not compatible", "BB.DEBUGGER") + sendWarnMessage("DebugPlus API version not compatible", "BB.DEBUGGER") return end local dp = dpAPI.registerID("BalatroBot") if not dp then - sendDebugMessage("Failed to register with DebugPlus", "BB.DEBUGGER") + sendWarnMessage("Failed to register with DebugPlus", "BB.DEBUGGER") return end diff --git a/src/lua/utils/logger.lua b/src/lua/utils/format.lua similarity index 87% rename from src/lua/utils/logger.lua rename to src/lua/utils/format.lua index dd08e58a..686cc77a 100644 --- a/src/lua/utils/logger.lua +++ b/src/lua/utils/format.lua @@ -1,10 +1,10 @@ --[[ - Logger utilities for BalatroBot - Provides helpers for consistent, readable log output + Format utilities for BalatroBot + Provides helpers for serializing values, params, and cards ]] ----@class BB_LOGGER -local BB_LOGGER = {} +---@class BB_FORMAT +local BB_FORMAT = {} --- Serialize a value for logging (handles tables, strings, etc.) ---@param value any @@ -51,7 +51,7 @@ end --- Examples: "({cards=[0,2,4], deck="RED"})" or "()" ---@param params table|nil ---@return string -function BB_LOGGER.serialize_params(params) +function BB_FORMAT.serialize_params(params) if params == nil or next(params) == nil then return "()" end @@ -67,7 +67,7 @@ end --- Format a playing card as "R♠" style (e.g., "A♠", "K♥", "10♦") ---@param card table The card object with card.base.value and card.base.suit ---@return string -function BB_LOGGER.format_playing_card(card) +function BB_FORMAT.format_playing_card(card) if not card or not card.base then return "?" end @@ -87,13 +87,13 @@ end ---@param cards table[] Array of card objects (1-based Lua array) ---@param indices integer[] Array of 0-based indices ---@return string Comma-separated card strings like "A♠, K♥, Q♦" -function BB_LOGGER.format_playing_cards(cards, indices) +function BB_FORMAT.format_playing_cards(cards, indices) local parts = {} for _, idx in ipairs(indices) do local card = cards[idx + 1] -- 0-based to 1-based - table.insert(parts, BB_LOGGER.format_playing_card(card)) + table.insert(parts, BB_FORMAT.format_playing_card(card)) end return table.concat(parts, ", ") end -return BB_LOGGER +return BB_FORMAT From 23253cce4b811414234cf8eb3d5d48d34b3ee4f9 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 13:06:18 +0200 Subject: [PATCH 053/121] feat(lua): add JSONL request/response recording When BALATROBOT_LOGS_PATH is set, the Lua server appends each request and response as a JSONL line to <port>.req.jsonl and <port>.res.jsonl respectively. The Python launcher now exposes this env var automatically so sessions are recorded out of the box. These trace files feed the new CLI replay mode introduced in the next commit. --- src/balatrobot/platforms/base.py | 1 + src/lua/core/server.lua | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/balatrobot/platforms/base.py b/src/balatrobot/platforms/base.py index 838f24f5..053c00ff 100644 --- a/src/balatrobot/platforms/base.py +++ b/src/balatrobot/platforms/base.py @@ -54,6 +54,7 @@ async def start(self, config: Config, session_dir: Path) -> subprocess.Popen: """ self.validate_paths(config) env = self.build_env(config) + env["BALATROBOT_LOGS_PATH"] = str(session_dir.resolve()) cmd = self.build_cmd(config) log_path = session_dir / f"{config.port}.log" diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index 7d164896..08d1533b 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -79,6 +79,9 @@ BB_SERVER = { current_request_id = nil, client_state = nil, openrpc_spec = nil, + -- JSONL recording + req_file = nil, + res_file = nil, } --- Create fresh client state for HTTP parsing @@ -131,6 +134,26 @@ function BB_SERVER.init() end sendInfoMessage("HTTP server listening on http://" .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") + + -- Open JSONL recording files if BALATROBOT_LOGS_PATH is set + local logs_path = os.getenv("BALATROBOT_LOGS_PATH") + if logs_path and logs_path ~= "" then + local req_path = logs_path .. "/" .. BB_SERVER.port .. ".req.jsonl" + local res_path = logs_path .. "/" .. BB_SERVER.port .. ".res.jsonl" + local rf, rf_err = io.open(req_path, "a") + if rf then + BB_SERVER.req_file = rf + else + sendDebugMessage("Cannot open req JSONL: " .. tostring(rf_err), "BB.SERVER") + end + local sf, sf_err = io.open(res_path, "a") + if sf then + BB_SERVER.res_file = sf + else + sendDebugMessage("Cannot open res JSONL: " .. tostring(sf_err), "BB.SERVER") + end + end + return true end @@ -340,6 +363,12 @@ local function handle_jsonrpc(body, dispatcher) BB_SERVER.current_request_id = parsed.id + -- Record request to JSONL + if BB_SERVER.req_file then + BB_SERVER.req_file:write(body .. "\n") + BB_SERVER.req_file:flush() + end + -- Dispatch to endpoint if dispatcher and dispatcher.dispatch then dispatcher.dispatch(parsed) @@ -416,6 +445,12 @@ function BB_SERVER.send_response(response) return false end + -- Record response to JSONL + if BB_SERVER.res_file then + BB_SERVER.res_file:write(json_str .. "\n") + BB_SERVER.res_file:flush() + end + -- Send HTTP response local http_response = format_http_response(200, "OK", json_str) local sent = send_raw(http_response) @@ -463,6 +498,16 @@ end function BB_SERVER.close() close_client() + -- Close JSONL recording files + if BB_SERVER.req_file then + BB_SERVER.req_file:close() + BB_SERVER.req_file = nil + end + if BB_SERVER.res_file then + BB_SERVER.res_file:close() + BB_SERVER.res_file = nil + end + if BB_SERVER.server_socket then BB_SERVER.server_socket:close() BB_SERVER.server_socket = nil From cde7516344aa934cc92718bac77dbd16859ea983 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 13:06:33 +0200 Subject: [PATCH 054/121] feat(cli): add replay mode to api command Add --requests and --responses flags that replay a JSONL trace file against a live server. With --responses, the command verifies each reply matches the expected result and reports divergences. tqdm shows progress when installed. Also move tqdm from test to main dependencies since it now ships to end users. --- pyproject.toml | 8 +- src/balatrobot/cli/api.py | 212 ++++++++++++++++++++++++++++++++++---- tests/cli/test_api_cmd.py | 193 ++++++++++++++++++++++++++++++++++ uv.lock | 4 +- 4 files changed, 391 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e083e628..10728c5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,12 @@ authors = [ { name = "phughesion" }, ] requires-python = ">=3.13" -dependencies = ["httpx>=0.28.1", "platformdirs>=4.0", "typer>=0.15"] +dependencies = [ + "httpx>=0.28.1", + "platformdirs>=4.0", + "typer>=0.15", + "tqdm>=4.67.1", +] classifiers = [ "Framework :: Pytest", "Intended Audience :: Developers", @@ -54,7 +59,6 @@ test = [ "pytest-asyncio>=1.3.0", "pytest-rerunfailures>=16.1", "pytest-xdist[psutil]>=3.8.0", - "tqdm>=4.67.1", ] dev = [ "commitizen>=4.11.0", diff --git a/src/balatrobot/cli/api.py b/src/balatrobot/cli/api.py index 71ac0e38..2392a705 100644 --- a/src/balatrobot/cli/api.py +++ b/src/balatrobot/cli/api.py @@ -2,6 +2,7 @@ import json from enum import StrEnum +from pathlib import Path from typing import Annotated import httpx @@ -37,16 +38,201 @@ class Method(StrEnum): USE = "use" +# --------------------------------------------------------------------------- +# Replay helpers +# --------------------------------------------------------------------------- + + +def _load_requests(path: Path) -> list[dict]: + """Load and validate a JSONL requests file. + + Returns list of parsed JSON-RPC request dicts. + Raises typer.Exit on validation failure. + """ + lines = path.read_text().splitlines() + if not lines: + typer.echo("Error: requests file is empty", err=True) + raise typer.Exit(code=1) + + requests: list[dict] = [] + for i, line in enumerate(lines, 1): + try: + obj = json.loads(line) + except json.JSONDecodeError as e: + typer.echo(f"Error: invalid JSON on line {i}: {e}", err=True) + raise typer.Exit(code=1) + if not isinstance(obj, dict) or "method" not in obj: + typer.echo(f"Error: line {i} is not a valid JSON-RPC request", err=True) + raise typer.Exit(code=1) + requests.append(obj) + return requests + + +def _load_responses(path: Path) -> list[dict]: + """Load a JSONL responses file. + + Returns list of parsed JSON-RPC response dicts. + Raises typer.Exit on validation failure. + """ + lines = path.read_text().splitlines() + responses: list[dict] = [] + for i, line in enumerate(lines, 1): + try: + obj = json.loads(line) + except json.JSONDecodeError as e: + typer.echo(f"Error: invalid JSON in responses on line {i}: {e}", err=True) + raise typer.Exit(code=1) + responses.append(obj) + return responses + + +def _replay( + requests: list[dict], + responses: list[dict] | None, + client: BalatroClient, +) -> None: + """Replay requests against a live server, optionally verifying responses. + + Raises typer.Exit on first error or divergence. + """ + try: + from tqdm import tqdm as _tqdm + + iterator = _tqdm(requests, desc="Replaying", unit="req") + except ImportError: + iterator = requests + + for i, req in enumerate(iterator): + method = req["method"] + params = req.get("params", {}) + + try: + result = client.call(method, params) + except APIError as e: + typer.echo( + f"\nError: API error on request {i + 1}: {e.name} - {e.message}", + err=True, + ) + raise typer.Exit(code=1) + except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e: + typer.echo(f"\nError: connection failed on request {i + 1}: {e}", err=True) + raise typer.Exit(code=1) + + if responses is not None: + expected = responses[i] + expected_result = expected.get("result") + if result != expected_result: + typer.echo(f"\nDivergence at request {i + 1}:", err=True) + typer.echo(f" expected: {json.dumps(expected, indent=2)}", err=True) + typer.echo( + f" actual: {json.dumps({'jsonrpc': '2.0', 'result': result, 'id': req.get('id')}, indent=2)}", + err=True, + ) + raise typer.Exit(code=1) + + typer.echo(f"Replayed {len(requests)} requests successfully.") + + +# --------------------------------------------------------------------------- +# Resolve host/port (shared between single-call and replay) +# --------------------------------------------------------------------------- + + +def _resolve_target( + host: str | None, + port: int | None, + index: int | None, +) -> tuple[str, int]: + """Resolve host and port from explicit values or state file.""" + if (host is None) != (port is None): + typer.echo("Error: --host and --port must be provided together.", err=True) + raise typer.Exit(code=1) + + if host is not None and port is not None: + return host, port + + try: + info = StateFile.resolve(index=index) + return info.host, info.port + except Exception as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + +# --------------------------------------------------------------------------- +# Command +# --------------------------------------------------------------------------- + + def api( - method: Annotated[Method, typer.Argument(help="API method to call")], - params: Annotated[str, typer.Argument(help="JSON params object")] = "{}", + method: Annotated[ + Method | None, + typer.Argument(help="API method to call"), + ] = None, + params: Annotated[ + str, + typer.Argument(help="JSON params object"), + ] = "{}", host: Annotated[str | None, typer.Option(help="Server hostname")] = None, port: Annotated[int | None, typer.Option(help="Server port")] = None, index: Annotated[ int | None, typer.Option("--index", "-i", help="Instance index (default: 0)") ] = None, + requests_path: Annotated[ + Path | None, + typer.Option("--requests", help="JSONL file of requests to replay"), + ] = None, + responses_path: Annotated[ + Path | None, + typer.Option("--responses", help="JSONL file of responses to verify against"), + ] = None, ) -> None: - """Call API endpoint on a running BalatroBot server.""" + """Call API endpoint on a running BalatroBot server. + + Use --requests to replay a JSONL trace file. Mutually exclusive with + positional METHOD and PARAMS arguments. + """ + # --requests is mutually exclusive with positional method/params + if requests_path is not None: + if method is not None: + typer.echo( + "Error: --requests is mutually exclusive with positional METHOD.", + err=True, + ) + raise typer.Exit(code=1) + + if not requests_path.exists(): + typer.echo(f"Error: requests file not found: {requests_path}", err=True) + raise typer.Exit(code=1) + + reqs = _load_requests(requests_path) + + resps: list[dict] | None = None + if responses_path is not None: + if not responses_path.exists(): + typer.echo( + f"Error: responses file not found: {responses_path}", err=True + ) + raise typer.Exit(code=1) + resps = _load_responses(responses_path) + if len(resps) != len(reqs): + typer.echo( + f"Error: line count mismatch — {len(reqs)} requests vs " + f"{len(resps)} responses", + err=True, + ) + raise typer.Exit(code=1) + + target_host, target_port = _resolve_target(host, port, index) + client = BalatroClient(host=target_host, port=target_port) + _replay(reqs, resps, client) + return + + # Single-call mode + if method is None: + typer.echo("Error: METHOD is required when not using --requests.", err=True) + raise typer.Exit(code=1) + # Validate JSON params try: params_dict = json.loads(params) @@ -54,25 +240,7 @@ def api( typer.echo(f"Error: Invalid JSON params - {e}", err=True) raise typer.Exit(code=1) - # Validate: --host and --port must be provided together or not at all - if (host is None) != (port is None): - typer.echo("Error: --host and --port must be provided together.", err=True) - raise typer.Exit(code=1) - - # Resolve instance: explicit host+port, or discover from state file - if host is not None and port is not None: - target_host = host - target_port = port - else: - try: - info = StateFile.resolve(index=index) - target_host = info.host - target_port = info.port - except Exception as e: - typer.echo(f"Error: {e}", err=True) - raise typer.Exit(code=1) - - # Make API call + target_host, target_port = _resolve_target(host, port, index) client = BalatroClient(host=target_host, port=target_port) try: result = client.call(method.value, params_dict) diff --git a/tests/cli/test_api_cmd.py b/tests/cli/test_api_cmd.py index 019853e8..91592026 100644 --- a/tests/cli/test_api_cmd.py +++ b/tests/cli/test_api_cmd.py @@ -1,6 +1,7 @@ """Integration tests for balatrobot api command.""" import json +from pathlib import Path from typer.testing import CliRunner @@ -142,3 +143,195 @@ def test_api_port_without_host(self, tmp_path, monkeypatch): result = runner.invoke(app, ["api", "health", "--port", "12346"]) assert result.exit_code == 1 assert "--host and --port must be provided together" in result.output + + +# ============================================================================ +# Replay tests (--requests / --responses) +# ============================================================================ + + +class TestReplayCommand: + """Test balatrobot api --requests / --responses replay.""" + + def _write_jsonl(self, path: Path, objects: list[dict]) -> None: + """Write a list of dicts as JSONL.""" + path.write_text("\n".join(json.dumps(o) for o in objects)) + + def test_replay_simple_sequence(self, cli_port: int, balatro_client: BalatroClient): + """Replay a small menu→health sequence → exit 0.""" + balatro_client.call("menu") # reset state + reqs = [ + {"jsonrpc": "2.0", "method": "menu", "params": {}, "id": 1}, + {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 2}, + ] + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in reqs: + f.write(json.dumps(r) + "\n") + req_path = f.name + + try: + result = runner.invoke( + app, + [ + "api", + "--requests", + req_path, + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 0, result.output + assert "Replayed 2 requests successfully" in result.output + finally: + Path(req_path).unlink(missing_ok=True) + + def test_replay_with_matching_responses( + self, cli_port: int, balatro_client: BalatroClient + ): + """Replay + verify with matching responses → exit 0.""" + balatro_client.call("menu") + # Capture actual responses first + actual_result = balatro_client.call("health") + + reqs = [ + {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 1}, + ] + resps = [ + {"jsonrpc": "2.0", "result": actual_result, "id": 1}, + ] + + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in reqs: + f.write(json.dumps(r) + "\n") + req_path = f.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in resps: + f.write(json.dumps(r) + "\n") + resp_path = f.name + + try: + result = runner.invoke( + app, + [ + "api", + "--requests", + req_path, + "--responses", + resp_path, + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 0, result.output + assert "Replayed 1 requests successfully" in result.output + finally: + Path(req_path).unlink(missing_ok=True) + Path(resp_path).unlink(missing_ok=True) + + def test_replay_with_diverging_responses( + self, cli_port: int, balatro_client: BalatroClient + ): + """Replay + verify with diverging responses → exit 1.""" + balatro_client.call("menu") + + reqs = [ + {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 1}, + ] + resps = [ + {"jsonrpc": "2.0", "result": {"status": "WRONG"}, "id": 1}, + ] + + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in reqs: + f.write(json.dumps(r) + "\n") + req_path = f.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in resps: + f.write(json.dumps(r) + "\n") + resp_path = f.name + + try: + result = runner.invoke( + app, + [ + "api", + "--requests", + req_path, + "--responses", + resp_path, + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 1, result.output + assert "Divergence" in result.output + finally: + Path(req_path).unlink(missing_ok=True) + Path(resp_path).unlink(missing_ok=True) + + def test_replay_empty_requests_file(self, tmp_path): + """Empty requests file → error.""" + req_file = tmp_path / "empty.jsonl" + req_file.write_text("") + + result = runner.invoke( + app, + ["api", "--requests", str(req_file), "--port", "1", "--host", "127.0.0.1"], + ) + assert result.exit_code == 1 + assert "empty" in result.output.lower() + + def test_replay_malformed_json_line(self, tmp_path): + """Malformed JSON line → error with line number.""" + req_file = tmp_path / "bad.jsonl" + req_file.write_text("{not valid json\n") + + result = runner.invoke( + app, + ["api", "--requests", str(req_file), "--port", "1", "--host", "127.0.0.1"], + ) + assert result.exit_code == 1 + assert "line 1" in result.output.lower() + + def test_replay_response_count_mismatch(self, tmp_path): + """Response count mismatch → error.""" + req_file = tmp_path / "req.jsonl" + req_file.write_text( + json.dumps({"jsonrpc": "2.0", "method": "health", "params": {}, "id": 1}) + + "\n" + + json.dumps({"jsonrpc": "2.0", "method": "health", "params": {}, "id": 2}) + + "\n" + ) + resp_file = tmp_path / "res.jsonl" + resp_file.write_text( + json.dumps({"jsonrpc": "2.0", "result": {"status": "ok"}, "id": 1}) + "\n" + ) + + result = runner.invoke( + app, + [ + "api", + "--requests", + str(req_file), + "--responses", + str(resp_file), + "--port", + "1", + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 1 + assert "mismatch" in result.output.lower() diff --git a/uv.lock b/uv.lock index 24a1e372..5ad6709c 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "platformdirs" }, + { name = "tqdm" }, { name = "typer" }, ] @@ -77,13 +78,13 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-rerunfailures" }, { name = "pytest-xdist", extra = ["psutil"] }, - { name = "tqdm" }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "platformdirs", specifier = ">=4.0" }, + { name = "tqdm", specifier = ">=4.67.1" }, { name = "typer", specifier = ">=0.15" }, ] @@ -108,7 +109,6 @@ test = [ { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-rerunfailures", specifier = ">=16.1" }, { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" }, - { name = "tqdm", specifier = ">=4.67.1" }, ] [[package]] From 145f504b0661f6a681555bbd930ea2d3f30cb63a Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 14:28:38 +0200 Subject: [PATCH 055/121] docs(skill): document JSONL traces and replay mode Update balatrobot skill to cover the automatic req.jsonl/res.jsonl recording and the new --requests/--responses flags on the api command. --- .agents/skills/balatrobot/SKILL.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index 3216bcd7..8ec62988 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -75,6 +75,13 @@ Full API reference (methods, errors, states): `docs/api.md`. ## Logs -Each instance has a log file (Balatro/Love2D output, Lovely traces, Lua errors, HTTP server logs). Find log paths via `balatrobot list` or `balatrobot list --json | jq '.instances[].log_path'`. +Each session directory (`logs/<timestamp>/`) contains per-instance files: `<port>.log` (Balatro/Love2D output, traces, errors), `<port>.req.jsonl` (JSON-RPC requests), `<port>.res.jsonl` (JSON-RPC responses). JSONL traces are written automatically by the Lua server. Find paths via `balatrobot list` or `balatrobot list --json | jq '.instances[].log_path'`. -Logs are stored under `logs/<timestamp>/<port>.log` (configurable via `--logs-path`). When `serve` fails or endpoints behave unexpectedly, check the log file. +## `api --requests` — replay & verify + +```bash +balatrobot api --requests logs/<ts>/<port>.req.jsonl +balatrobot api --requests logs/<ts>/<port>.req.jsonl --responses logs/<ts>/<port>.res.jsonl +``` + +Replays a JSONL request trace against a running instance. `--responses` compares each live response against the recorded one (exits on first divergence). Mutually exclusive with positional `METHOD`. From 1785b3fb418fe59f4cc958bd35a5a9fbcf0b6421 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 15:59:16 +0200 Subject: [PATCH 056/121] fix(pack): handle return to BLIND_SELECT after tag-reward packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Charm Tag (or similar) opens a free booster pack from BLIND_SELECT, skipping or selecting all cards must return the game to BLIND_SELECT — not SHOP. The polling loop now checks for both states so it doesn't hang waiting for SHOP after a tag-triggered pack. This issue was first noted in PR #190. Co-authored-by: icebear <icebear0828@users.noreply.github.com> --- src/lua/endpoints/pack.lua | 6 +++--- tests/fixtures/fixtures.json | 22 ++++++++++++++++++++++ tests/lua/endpoints/test_pack.py | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index 5a99ebf6..3e7ee8a4 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -262,9 +262,9 @@ return { return true end else - -- Pack closes - wait for return to shop + -- Pack closes - wait for return to shop or blind select local pack_closed = not G.pack_cards or G.pack_cards.REMOVED - local back_to_shop = G.STATE == G.STATES.SHOP + local back_to_shop = G.STATE == G.STATES.SHOP or G.STATE == G.STATES.BLIND_SELECT if pack_closed and back_to_shop then sendDebugMessage("pack() → selected", "BB.ENDPOINTS") @@ -291,7 +291,7 @@ return { 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 back_to_shop = G.STATE == G.STATES.SHOP or G.STATE == G.STATES.BLIND_SELECT if pack_closed and back_to_shop then sendDebugMessage("pack() → skipped", "BB.ENDPOINTS") diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 44ceffae..c4e542cb 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1936,6 +1936,28 @@ "pack": 0 } } + ], + "seed-TAGTEST2--state-SMODS_BOOSTER_OPENED--blinds.small.tag.key-tag_charm": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "RED", + "stake": "WHITE", + "seed": "TAGTEST2" + } + }, + { + "method": "skip", + "params": {} + }, + { + "method": "gamestate", + "params": {} + } ] }, "sell": { diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 0e60555b..2743a308 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -514,6 +514,24 @@ def test_pack_skip(self, client: httpx.Client, pack_key: str) -> None: gamestate = assert_gamestate_response(result, state="SHOP") assert "pack" not in gamestate + def test_pack_skip_from_tag_reward(self, client: httpx.Client) -> None: + # Test skipping a pack opened by Charm Tag returns to BLIND_SELECT. + # + # When skipping a blind with a Charm Tag, a free Mega Arcana Pack opens + # from BLIND_SELECT. After skipping the pack, the game must return to + # BLIND_SELECT (not SHOP) because the pack interrupted that state. + + load_fixture( + client, + "pack", + "seed-TAGTEST2--state-SMODS_BOOSTER_OPENED--blinds.small.tag.key-tag_charm", + ) + + result = api(client, "pack", {"skip": True}) + gamestate = assert_gamestate_response(result) + assert gamestate["state"] == "BLIND_SELECT" + assert "pack" not in gamestate + # ============================================================================= # Schema Validation Tests From c8ec29eb737f7142313ef6e6f7281105fa9e4c6c Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 17:43:10 +0200 Subject: [PATCH 057/121] feat(lua): add boss parameter to set endpoint Add a boss parameter to the set endpoint that overrides which Boss Blind appears during the blind selection screen. This enables testing boss-specific bugs without playing through multiple antes. Implementation uses the game's own perscribed_bosses mechanism with G.FUNCS.reroll_boss, bypassing the charge via G.from_boss_tag. The response awaits the controller lock release before returning the updated gamestate. Validation ensures: - Only callable in BLIND_SELECT state - Boss blind state must be Upcoming - Key must exist in G.P_BLINDS and have .boss = true - Mutually exclusive with the shop parameter Also adds key field to the Blind type in gamestate output, Blind.Key enum alias, OpenRPC spec update, and docs with boss blind key reference table. Tests: 7 unit tests + 1 integration test (set -> skip -> select -> play -> verify boss name). --- docs/api.md | 60 ++++++++++++++--- src/lua/endpoints/set.lua | 73 ++++++++++++++++++++ src/lua/utils/enums.lua | 32 +++++++++ src/lua/utils/gamestate.lua | 5 ++ src/lua/utils/openrpc.json | 13 ++++ src/lua/utils/types.lua | 1 + tests/fixtures/fixtures.json | 14 ++++ tests/lua/endpoints/test_set.py | 114 ++++++++++++++++++++++++++++++++ 8 files changed, 302 insertions(+), 10 deletions(-) diff --git a/docs/api.md b/docs/api.md index c93b7fc1..093c5554 100644 --- a/docs/api.md +++ b/docs/api.md @@ -656,27 +656,33 @@ Set in-game values (debug/testing). **Parameters:** (at least one required) -| Name | Type | Required | Description | -| ---------- | ------- | -------- | ------------------------------- | -| `money` | integer | No | Set money amount | -| `chips` | integer | No | Set chips scored | -| `ante` | integer | No | Set ante number | -| `round` | integer | No | Set round number | -| `hands` | integer | No | Set hands remaining | -| `discards` | integer | No | Set discards remaining | -| `shop` | boolean | No | Re-stock shop (SHOP state only) | +| Name | Type | Required | Description | +| ---------- | ------- | -------- | ---------------------------------------------------------------- | +| `money` | integer | No | Set money amount | +| `chips` | integer | No | Set chips scored | +| `ante` | integer | No | Set ante number | +| `round` | integer | No | Set round number | +| `hands` | integer | No | Set hands remaining | +| `discards` | integer | No | Set discards remaining | +| `shop` | boolean | No | Re-stock shop (SHOP state only) | +| `boss` | string | No | Override Boss Blind (BLIND\_SELECT state, boss must be Upcoming) | **Returns:** [GameState](#gamestate-schema) **Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED` -**Example:** +**Examples:** ```bash # Set money to 100 and hands to 5 curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "set", "params": {"money": 100, "hands": 5}, "id": 1}' + +# Override boss blind to The Hook +curl -X POST http://127.0.0.1:12346 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc": "2.0", "method": "set", "params": {"boss": "bl_hook"}, "id": 1}' ``` --- @@ -776,6 +782,7 @@ Represents a card area (hand, jokers, consumables, shop, etc.). ```json { + "key": "bl_small", "type": "SMALL", "status": "SELECT", "name": "Small Blind", @@ -931,6 +938,39 @@ Represents a Balatro tag that provides bonuses when triggered. | `BIG` | Can be skipped for a Tag | | `BOSS` | Cannot be skipped, has special effect | +### Boss Blind Keys + +| Key | Name | +| ----------------- | ---------------- | +| `bl_hook` | The Hook | +| `bl_ox` | The Ox | +| `bl_mouth` | The Mouth | +| `bl_fish` | The Fish | +| `bl_club` | The Club | +| `bl_manacle` | The Manacle | +| `bl_tooth` | The Tooth | +| `bl_wall` | The Wall | +| `bl_house` | The House | +| `bl_mark` | The Mark | +| `bl_wheel` | The Wheel | +| `bl_arm` | The Arm | +| `bl_psychic` | The Psychic | +| `bl_goad` | The Goad | +| `bl_water` | The Water | +| `bl_eye` | The Eye | +| `bl_plant` | The Plant | +| `bl_needle` | The Needle | +| `bl_head` | The Head | +| `bl_window` | The Window | +| `bl_serpent` | The Serpent | +| `bl_pillar` | The Pillar | +| `bl_flint` | The Flint | +| `bl_final_bell` | The Bell | +| `bl_final_leaf` | The Leaf | +| `bl_final_vessel` | The Vessel | +| `bl_final_acorn` | The Acorn | +| `bl_final_heart` | The Heart | + ### Blind Status | Value | Description | diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index d2bfc2b8..56eac61c 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -12,6 +12,7 @@ ---@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 boss string? Override which Boss Blind appears -- ========================================================================== -- Set Endpoint @@ -60,6 +61,11 @@ return { required = false, description = "Re-stock shop with new items", }, + boss = { + type = "string", + required = false, + description = "Override which Boss Blind appears", + }, }, requires_state = nil, @@ -92,6 +98,9 @@ return { if args.shop ~= nil then table.insert(fields, "shop") end + if args.boss ~= nil then + table.insert(fields, "boss=" .. tostring(args.boss)) + end sendInfoMessage("Setting " .. table.concat(fields, ", "), "BB.ENDPOINTS") -- Validate we're in a run @@ -112,6 +121,7 @@ return { and args.hands == nil and args.discards == nil and args.shop == nil + and args.boss == nil then send_response({ message = "Must provide at least one field to set", @@ -120,6 +130,51 @@ return { return end + -- Boss + shop mutual exclusion + if args.boss and args.shop then + send_response({ + message = "Cannot set boss and shop at the same time", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + + -- Boss validation + if args.boss then + if G.STATE ~= G.STATES.BLIND_SELECT then + send_response({ + message = "Can only set boss blind during blind selection (BLIND_SELECT state)", + name = BB_ERROR_NAMES.INVALID_STATE, + }) + return + end + + local boss_state = G.GAME.round_resets.blind_states.Boss + if boss_state ~= "Upcoming" then + send_response({ + message = "Boss blind is not selectable (current state: " .. tostring(boss_state) .. ")", + name = BB_ERROR_NAMES.INVALID_STATE, + }) + return + end + + if not G.P_BLINDS[args.boss] then + send_response({ + message = "Unknown boss blind key: " .. args.boss, + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + + if not G.P_BLINDS[args.boss].boss then + send_response({ + message = "Not a boss blind: " .. args.boss, + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + end + -- Set money if args.money then if args.money < 0 then @@ -213,6 +268,14 @@ return { G:update_shop() end + -- Boss execution: inject desired boss via perscribed_bosses and reroll + if args.boss then + G.GAME.perscribed_bosses = G.GAME.perscribed_bosses or {} + G.GAME.perscribed_bosses[G.GAME.round_resets.ante] = args.boss + G.from_boss_tag = true + G.FUNCS.reroll_boss() + end + G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, @@ -228,6 +291,16 @@ return { return true end return false + elseif args.boss then + -- Wait for boss reroll to complete (controller lock releases after 0.5s) + if G.CONTROLLER.locks.boss_reroll == nil then + G.GAME.round_resets.boss_rerolled = false + sendDebugMessage("set() → ok (boss)", "BB.ENDPOINTS") + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + return true + end + return false else sendDebugMessage("set() → ok", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index f236eff9..f76b7a64 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -412,6 +412,38 @@ ---| "DEFEATED" # Previously defeated blind ---| "SKIPPED" # Previously skipped blind +---@alias Blind.Key +---| "bl_small" +---| "bl_big" +---| "bl_hook" +---| "bl_ox" +---| "bl_mouth" +---| "bl_fish" +---| "bl_club" +---| "bl_manacle" +---| "bl_tooth" +---| "bl_wall" +---| "bl_house" +---| "bl_mark" +---| "bl_final_bell" +---| "bl_wheel" +---| "bl_arm" +---| "bl_psychic" +---| "bl_goad" +---| "bl_water" +---| "bl_eye" +---| "bl_plant" +---| "bl_needle" +---| "bl_head" +---| "bl_final_leaf" +---| "bl_final_vessel" +---| "bl_window" +---| "bl_serpent" +---| "bl_pillar" +---| "bl_flint" +---| "bl_final_acorn" +---| "bl_final_heart" + ---@alias Tag.Key ---| "tag_uncommon" # Uncommon Tag: Shop has a free Uncommon Joker ---| "tag_rare" # Rare Tag: Shop has a free Rare Joker diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 359d76ef..90aa3d66 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -744,6 +744,7 @@ function gamestate.get_blinds_info() -- Small Blind -- ==================== local small_choice = blind_choices.Small or "bl_small" + blinds.small.key = small_choice if G.P_BLINDS and G.P_BLINDS[small_choice] then local small_blind = G.P_BLINDS[small_choice] blinds.small.name = small_blind.name or "Small Blind" @@ -771,6 +772,7 @@ function gamestate.get_blinds_info() -- Big Blind -- ==================== local big_choice = blind_choices.Big or "bl_big" + blinds.big.key = big_choice if G.P_BLINDS and G.P_BLINDS[big_choice] then local big_blind = G.P_BLINDS[big_choice] blinds.big.name = big_blind.name or "Big Blind" @@ -798,6 +800,9 @@ function gamestate.get_blinds_info() -- Boss Blind -- ==================== local boss_choice = blind_choices.Boss + if boss_choice then + blinds.boss.key = boss_choice + end if boss_choice and G.P_BLINDS and G.P_BLINDS[boss_choice] then local boss_blind = G.P_BLINDS[boss_choice] blinds.boss.name = boss_blind.name or "Boss Blind" diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 5d272ae1..f311f711 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -693,6 +693,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "boss", + "description": "Override which Boss Blind appears (only in BLIND_SELECT state, boss must be Upcoming)", + "required": false, + "schema": { + "type": "string" + } } ], "result": { @@ -1090,6 +1098,10 @@ "type": "object", "description": "Blind information", "properties": { + "key": { + "type": "string", + "description": "Key of the blind (e.g., 'bl_small', 'bl_hook')" + }, "type": { "$ref": "#/components/schemas/BlindType" }, @@ -1114,6 +1126,7 @@ } }, "required": [ + "key", "type", "status", "name", diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 3f2f3854..fa357780 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -54,6 +54,7 @@ ---@field effect string Description of the tag's effect ---@class Blind +---@field key Blind.Key Key of the blind (e.g., "bl_small", "bl_hook") ---@field type Blind.Type Type of the blind ---@field status Blind.Status Status of the bilnd ---@field name string Name of the blind (e.g., "Small", "Big" or the Boss name) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index c4e542cb..7c7c94d5 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -243,6 +243,20 @@ ] }, "set": { + "state-BLIND_SELECT": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], "state-SELECTING_HAND": [ { "method": "menu", diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index 65fb900c..08e02776 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -258,3 +258,117 @@ def test_invalid_shop_type(self, client: httpx.Client): "BAD_REQUEST", "Field 'shop' must be of type boolean", ) + + +class TestSetEndpointBoss: + """Test set endpoint boss blind functionality.""" + + def test_set_boss_not_in_blind_select(self, client: httpx.Client) -> None: + """Test that set boss fails when not in BLIND_SELECT state.""" + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" + response = api(client, "set", {"boss": "bl_hook"}) + assert_error_response( + response, + "INVALID_STATE", + "Can only set boss blind during blind selection (BLIND_SELECT state)", + ) + + def test_set_boss_invalid_key(self, client: httpx.Client) -> None: + """Test that set boss fails when key does not exist.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_nonsense"}) + assert_error_response( + response, + "BAD_REQUEST", + "Unknown boss blind key: bl_nonsense", + ) + + def test_set_boss_not_a_boss(self, client: httpx.Client) -> None: + """Test that set boss fails when key is not a boss blind (e.g. bl_small).""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_small"}) + assert_error_response( + response, + "BAD_REQUEST", + "Not a boss blind: bl_small", + ) + + def test_set_boss_success(self, client: httpx.Client) -> None: + """Test that set boss succeeds and gamestate reflects new boss key.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_hook"}) + after = assert_gamestate_response(response, state="BLIND_SELECT") + assert after["blinds"]["boss"]["key"] == "bl_hook" + + def test_set_boss_with_scalar(self, client: httpx.Client) -> None: + """Test that set boss combined with money works.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_hook", "money": 100}) + after = assert_gamestate_response(response, state="BLIND_SELECT", money=100) + assert after["blinds"]["boss"]["key"] == "bl_hook" + + def test_set_boss_with_shop(self, client: httpx.Client) -> None: + """Test that set boss + shop is rejected (mutual exclusion).""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_hook", "shop": True}) + assert_error_response( + response, + "BAD_REQUEST", + "Cannot set boss and shop at the same time", + ) + + def test_set_boss_invalid_type(self, client: httpx.Client) -> None: + """Test that set boss fails when boss parameter is not a string.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": 123}) + assert_error_response( + response, + "BAD_REQUEST", + "Field 'boss' must be of type string", + ) + + +class TestSetEndpointBossIntegration: + """Integration test for boss blind override.""" + + def test_set_boss_integration(self, client: httpx.Client) -> None: + """Set boss → skip small → skip big → select boss → play → verify boss name.""" + # Start in BLIND_SELECT + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + + # Override boss blind to bl_hook (The Hook) + response = api(client, "set", {"boss": "bl_hook"}) + after = assert_gamestate_response(response, state="BLIND_SELECT") + assert after["blinds"]["boss"]["key"] == "bl_hook" + + # Skip small blind + response = api(client, "skip", {}) + after = assert_gamestate_response(response) + assert after["blinds"]["small"]["status"] == "SKIPPED" + + # Skip big blind + response = api(client, "skip", {}) + after = assert_gamestate_response(response) + assert after["blinds"]["big"]["status"] == "SKIPPED" + + # Select boss blind + response = api(client, "select", {}) + after = assert_gamestate_response(response, state="SELECTING_HAND") + assert after["blinds"]["boss"]["status"] == "CURRENT" + assert after["blinds"]["boss"]["key"] == "bl_hook" + assert "Hook" in after["blinds"]["boss"]["name"] + + # Beat the boss: set high chips and play a card + api(client, "set", {"chips": 1000000}) + response = api(client, "play", {"cards": [0]}) + after = assert_gamestate_response(response) + # After beating the boss, we should be in ROUND_EVAL or SHOP + assert after["state"] in ("ROUND_EVAL", "SHOP") From 7121ecb11112762cb54c6c03dc9509d3e738c554 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 20:55:43 +0200 Subject: [PATCH 058/121] docs(lua): add descriptions to Blind.Key enum values Add inline # comments describing each blind's effect, matching the style used by other enums in the file. Showdown bosses are grouped at the end and tagged with (Showdown). --- src/lua/utils/enums.lua | 60 ++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index f76b7a64..88b2275f 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -413,36 +413,36 @@ ---| "SKIPPED" # Previously skipped blind ---@alias Blind.Key ----| "bl_small" ----| "bl_big" ----| "bl_hook" ----| "bl_ox" ----| "bl_mouth" ----| "bl_fish" ----| "bl_club" ----| "bl_manacle" ----| "bl_tooth" ----| "bl_wall" ----| "bl_house" ----| "bl_mark" ----| "bl_final_bell" ----| "bl_wheel" ----| "bl_arm" ----| "bl_psychic" ----| "bl_goad" ----| "bl_water" ----| "bl_eye" ----| "bl_plant" ----| "bl_needle" ----| "bl_head" ----| "bl_final_leaf" ----| "bl_final_vessel" ----| "bl_window" ----| "bl_serpent" ----| "bl_pillar" ----| "bl_flint" ----| "bl_final_acorn" ----| "bl_final_heart" +---| "bl_small" # Small Blind: No special effects - can be skipped to receive a Tag +---| "bl_big" # Big Blind: No special effects - can be skipped to receive a Tag +---| "bl_hook" # The Hook: Discards 2 random cards held in hand after every played hand +---| "bl_ox" # The Ox: Playing the most played hand this run sets money to $0 +---| "bl_mouth" # The Mouth: Only one hand type can be played this round +---| "bl_fish" # The Fish: Cards drawn face down after each hand played +---| "bl_club" # The Club: All Club cards are debuffed +---| "bl_manacle" # The Manacle: −1 Hand Size +---| "bl_tooth" # The Tooth: Lose $1 per card played +---| "bl_wall" # The Wall: Extra large blind (4× base chips required) +---| "bl_house" # The House: First hand is drawn face down +---| "bl_mark" # The Mark: All face cards are drawn face down +---| "bl_wheel" # The Wheel: 1 in 7 cards get drawn face down during the round +---| "bl_arm" # The Arm: Decrease level of played poker hand by 1 +---| "bl_psychic" # The Psychic: Must play 5 cards (not all cards need to score) +---| "bl_goad" # The Goad: All Spade cards are debuffed +---| "bl_water" # The Water: Start with 0 discards +---| "bl_eye" # The Eye: No repeat hand types this round +---| "bl_plant" # The Plant: All face cards are debuffed +---| "bl_needle" # The Needle: Play only 1 hand +---| "bl_head" # The Head: All Heart cards are debuffed +---| "bl_window" # The Window: All Diamond cards are debuffed +---| "bl_serpent" # The Serpent: After Play or Discard, always draw 3 cards (ignores hand size) +---| "bl_pillar" # The Pillar: Cards played previously this Ante are debuffed +---| "bl_flint" # The Flint: Base Chips and Mult for played poker hands are halved +---| "bl_final_acorn" # Amber Acorn: Flips and shuffles all Joker cards (Showdown) +---| "bl_final_bell" # Cerulean Bell: Forces 1 card to always be selected (Showdown) +---| "bl_final_heart" # Crimson Heart: One random Joker disabled every hand (Showdown) +---| "bl_final_leaf" # Verdant Leaf: All cards debuffed until 1 Joker is sold (Showdown) +---| "bl_final_vessel" # Violet Vessel: Very large blind, 6× base chips required (Showdown) ---@alias Tag.Key ---| "tag_uncommon" # Uncommon Tag: Shop has a free Uncommon Joker From 5800c4c6ae67d4814a4bd28ab6a881ba4422fd97 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 21:06:51 +0200 Subject: [PATCH 059/121] test: add `key` field to expected blinds in test_blinds_structure_extraction The API now returns a `key` field on each blind (`bl_small`, `bl_big`, `bl_manacle`). Update the expected dict to include these. --- tests/lua/endpoints/test_gamestate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index a4ba0536..ea725d4b 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -145,6 +145,7 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: expected_blinds = { "small": { "type": "SMALL", + "key": "bl_small", "name": "Small Blind", "effect": "", "score": 300, @@ -156,6 +157,7 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: }, "big": { "type": "BIG", + "key": "bl_big", "name": "Big Blind", "effect": "", "score": 450, @@ -167,6 +169,7 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: }, "boss": { "type": "BOSS", + "key": "bl_manacle", "name": "The Manacle", "effect": "-1 Hand Size", "score": 600, From b096965f7759ceb70e72d2e4487201785f830bdf Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 21:09:08 +0200 Subject: [PATCH 060/121] fix(play,discard): reject plays/discards missing Cerulean Bell forced card The Cerulean Bell boss blind (bl_final_bell) forces one random card to always be selected via card.ability.forced_selection. Both endpoints called unhighlight_all() which preserves forced cards, causing the forced card to silently leak into the played/discarded hand alongside the user's requested cards. Add validation that checks for forced_selection on any card in hand and rejects with BAD_REQUEST if the forced card is not included in the request. Replace unhighlight_all() with targeted logic that only clears non-forced highlights and only clicks cards not already highlighted, avoiding reliance on toggle no-op behavior. This issue was first notice in PR #190 Co-authored-by: icebear <icebear0828@users.noreply.github.com> --- src/lua/endpoints/discard.lua | 38 ++++++++++++++++++++++++++++++----- src/lua/endpoints/play.lua | 38 ++++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index a1ab2363..f38731e7 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -70,13 +70,41 @@ 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() + -- Validate forced-selection cards (e.g. Cerulean Bell boss blind) + -- If any card has forced_selection, it MUST be included in the discard + for i = 1, #G.hand.cards do + local card = G.hand.cards[i] + if card.ability and card.ability.forced_selection then + local included = false + for _, card_index in ipairs(args.cards) do + if card_index + 1 == i then + included = true + break + end + end + if not included then + send_response({ + message = "Card at index " .. (i - 1) .. " is forced-selected by the boss blind. Include it in your discard.", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + end + end + -- Clear non-forced highlights only (preserves forced-selection cards) + for i = #G.hand.highlighted, 1, -1 do + if not G.hand.highlighted[i].ability.forced_selection then + G.hand.highlighted[i]:highlight(false) + table.remove(G.hand.highlighted, i) + end + end + + -- Click only cards not already highlighted for _, card_index in ipairs(args.cards) do - G.hand.cards[card_index + 1]:click() + if not G.hand.cards[card_index + 1].highlighted then + G.hand.cards[card_index + 1]:click() + end end -- Log the cards being discarded diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 5c738b60..7165491c 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -62,13 +62,41 @@ 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() + -- Validate forced-selection cards (e.g. Cerulean Bell boss blind) + -- If any card has forced_selection, it MUST be included in the play + for i = 1, #G.hand.cards do + local card = G.hand.cards[i] + if card.ability and card.ability.forced_selection then + local included = false + for _, card_index in ipairs(args.cards) do + if card_index + 1 == i then + included = true + break + end + end + if not included then + send_response({ + message = "Card at index " .. (i - 1) .. " is forced-selected by the boss blind. Include it in your play.", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + end + end + -- Clear non-forced highlights only (preserves forced-selection cards) + for i = #G.hand.highlighted, 1, -1 do + if not G.hand.highlighted[i].ability.forced_selection then + G.hand.highlighted[i]:highlight(false) + table.remove(G.hand.highlighted, i) + end + end + + -- Click only cards not already highlighted for _, card_index in ipairs(args.cards) do - G.hand.cards[card_index + 1]:click() + if not G.hand.cards[card_index + 1].highlighted then + G.hand.cards[card_index + 1]:click() + end end -- Log the cards being played From 77bab0c39c73f351ab9d962541c1b39dce1523aa Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 21:09:16 +0200 Subject: [PATCH 061/121] test(fixtures): add Cerulean Bell boss blind fixture for play and discard Add state-SELECTING_HAND--blinds.boss.key-bl_final_bell fixture under both play and discard sections in fixtures.json. Setup uses set boss bl_final_bell, skip small and big blinds, then select boss. --- tests/fixtures/fixtures.json | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 7c7c94d5..58e75437 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -571,6 +571,38 @@ "chips": 1000000 } } + ], + "state-SELECTING_HAND--blinds.boss.key-bl_final_bell": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "method": "set", + "params": { + "boss": "bl_final_bell" + } + }, + { + "method": "skip", + "params": {} + }, + { + "method": "skip", + "params": {} + }, + { + "method": "select", + "params": {} + } ] }, "discard": { @@ -629,6 +661,38 @@ "discards": 0 } } + ], + "state-SELECTING_HAND--blinds.boss.key-bl_final_bell": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "method": "set", + "params": { + "boss": "bl_final_bell" + } + }, + { + "method": "skip", + "params": {} + }, + { + "method": "skip", + "params": {} + }, + { + "method": "select", + "params": {} + } ] }, "cash_out": { @@ -2863,4 +2927,4 @@ } ] } -} +} \ No newline at end of file From f7dbf2cc592718592324b2ea4b2a5b719c520e5c Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 21:09:23 +0200 Subject: [PATCH 062/121] test(play,discard): add Cerulean Bell forced-card validation tests Add test_cerulean_bell_forced_card_not_included_in_play and test_cerulean_bell_forced_card_not_included_in_discard. Both load the bl_final_bell fixture, find the forced-highlighted card, then attempt to play/discard a different card and assert a BAD_REQUEST error containing 'forced-selected by the boss blind'. --- tests/lua/endpoints/test_discard.py | 28 ++++++++++++++++++++++++++++ tests/lua/endpoints/test_play.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py index 422deae2..1b199507 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -96,6 +96,34 @@ def test_invalid_cards_type(self, client: httpx.Client): "Field 'cards' must be an array", ) + def test_cerulean_bell_forced_card_not_included_in_discard( + self, client: httpx.Client + ) -> None: + """Discard a single non-forced card; the forced card must NOT be silently included.""" + + prev_gs = load_fixture( + client, "discard", "state-SELECTING_HAND--blinds.boss.key-bl_final_bell" + ) + assert prev_gs["blinds"]["boss"]["key"] == "bl_final_bell" + + # Find the forced card (highlighted by The Bell) + h_idx, h_card = None, None + for i, c in enumerate(prev_gs["hand"]["cards"]): + if isinstance(c["state"], dict) and c["state"]["highlight"]: + h_idx = i + h_card = c + break + assert h_card is not None, "The Bell should force exactly one card" + assert h_idx is not None, "The Bell should force exactly one card" + + # Select another card to discard, this should raise an error in the API. + response = api(client, "discard", {"cards": [1 if h_idx == 0 else 0]}) + assert_error_response( + response, + "BAD_REQUEST", + "forced-selected by the boss blind", + ) + class TestDiscardEndpointStateRequirements: """Test discard endpoint state requirements.""" diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 8dee5527..6960ba7e 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -110,6 +110,34 @@ def test_invalid_cards_type(self, client: httpx.Client): "Field 'cards' must be an array", ) + def test_cerulean_bell_forced_card_not_included_in_play( + self, client: httpx.Client + ) -> None: + """Play a single non-forced card; the forced card must NOT be included.""" + + prev_gs = load_fixture( + client, "play", "state-SELECTING_HAND--blinds.boss.key-bl_final_bell" + ) + assert prev_gs["blinds"]["boss"]["key"] == "bl_final_bell" + + # Find the forced card (highlighted by The Bell) + h_idx, h_card = None, None + for i, c in enumerate(prev_gs["hand"]["cards"]): + if isinstance(c["state"], dict) and c["state"]["highlight"]: + h_idx = i + h_card = c + break + assert h_card is not None, "The Bell should force exactly one card" + assert h_idx is not None, "The Bell should force exactly one card" + + # Select another card to play, this should raise an error in the API. + response = api(client, "play", {"cards": [1 if h_idx == 0 else 0]}) + assert_error_response( + response, + "BAD_REQUEST", + "forced-selected by the boss blind", + ) + class TestPlayEndpointStateRequirements: """Test play endpoint state requirements.""" From 09d9e20d05e1adaad55b57815b7db67c1f8b671c Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Tue, 9 Jun 2026 21:26:32 +0200 Subject: [PATCH 063/121] style: trim trailing whitespace in tables, wrap long line Remove trailing whitespace in docs/api.md set_value and boss-blind tables. Split the long string-concatenation line in discard.lua error message for readability. --- docs/api.md | 80 +++++++++++++++++------------------ src/lua/endpoints/discard.lua | 4 +- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/docs/api.md b/docs/api.md index 093c5554..79cc2bad 100644 --- a/docs/api.md +++ b/docs/api.md @@ -656,16 +656,16 @@ Set in-game values (debug/testing). **Parameters:** (at least one required) -| Name | Type | Required | Description | -| ---------- | ------- | -------- | ---------------------------------------------------------------- | -| `money` | integer | No | Set money amount | -| `chips` | integer | No | Set chips scored | -| `ante` | integer | No | Set ante number | -| `round` | integer | No | Set round number | -| `hands` | integer | No | Set hands remaining | -| `discards` | integer | No | Set discards remaining | -| `shop` | boolean | No | Re-stock shop (SHOP state only) | -| `boss` | string | No | Override Boss Blind (BLIND\_SELECT state, boss must be Upcoming) | +| Name | Type | Required | Description | +| ---------- | ------- | -------- | --------------------------------------------------------------- | +| `money` | integer | No | Set money amount | +| `chips` | integer | No | Set chips scored | +| `ante` | integer | No | Set ante number | +| `round` | integer | No | Set round number | +| `hands` | integer | No | Set hands remaining | +| `discards` | integer | No | Set discards remaining | +| `shop` | boolean | No | Re-stock shop (SHOP state only) | +| `boss` | string | No | Override Boss Blind (BLIND_SELECT state, boss must be Upcoming) | **Returns:** [GameState](#gamestate-schema) @@ -940,36 +940,36 @@ Represents a Balatro tag that provides bonuses when triggered. ### Boss Blind Keys -| Key | Name | -| ----------------- | ---------------- | -| `bl_hook` | The Hook | -| `bl_ox` | The Ox | -| `bl_mouth` | The Mouth | -| `bl_fish` | The Fish | -| `bl_club` | The Club | -| `bl_manacle` | The Manacle | -| `bl_tooth` | The Tooth | -| `bl_wall` | The Wall | -| `bl_house` | The House | -| `bl_mark` | The Mark | -| `bl_wheel` | The Wheel | -| `bl_arm` | The Arm | -| `bl_psychic` | The Psychic | -| `bl_goad` | The Goad | -| `bl_water` | The Water | -| `bl_eye` | The Eye | -| `bl_plant` | The Plant | -| `bl_needle` | The Needle | -| `bl_head` | The Head | -| `bl_window` | The Window | -| `bl_serpent` | The Serpent | -| `bl_pillar` | The Pillar | -| `bl_flint` | The Flint | -| `bl_final_bell` | The Bell | -| `bl_final_leaf` | The Leaf | -| `bl_final_vessel` | The Vessel | -| `bl_final_acorn` | The Acorn | -| `bl_final_heart` | The Heart | +| Key | Name | +| ----------------- | ----------- | +| `bl_hook` | The Hook | +| `bl_ox` | The Ox | +| `bl_mouth` | The Mouth | +| `bl_fish` | The Fish | +| `bl_club` | The Club | +| `bl_manacle` | The Manacle | +| `bl_tooth` | The Tooth | +| `bl_wall` | The Wall | +| `bl_house` | The House | +| `bl_mark` | The Mark | +| `bl_wheel` | The Wheel | +| `bl_arm` | The Arm | +| `bl_psychic` | The Psychic | +| `bl_goad` | The Goad | +| `bl_water` | The Water | +| `bl_eye` | The Eye | +| `bl_plant` | The Plant | +| `bl_needle` | The Needle | +| `bl_head` | The Head | +| `bl_window` | The Window | +| `bl_serpent` | The Serpent | +| `bl_pillar` | The Pillar | +| `bl_flint` | The Flint | +| `bl_final_bell` | The Bell | +| `bl_final_leaf` | The Leaf | +| `bl_final_vessel` | The Vessel | +| `bl_final_acorn` | The Acorn | +| `bl_final_heart` | The Heart | ### Blind Status diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index f38731e7..0503714f 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -84,7 +84,9 @@ return { end if not included then send_response({ - message = "Card at index " .. (i - 1) .. " is forced-selected by the boss blind. Include it in your discard.", + message = "Card at index " + .. (i - 1) + .. " is forced-selected by the boss blind. Include it in your discard.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return From b430b9a54e4dd245f2e45a265f1338dc088c192c Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 13:14:33 +0200 Subject: [PATCH 064/121] docs: add ADR 0001 for settings redesign and update glossary Record the decision to replace individual CLI flags with balatrosettings profiles (ADR 0001). Add glossary entries for profile, BalatroBot profile, and render mode to CONTEXT.md so the ADR terminology is grounded in the project vocabulary. --- CONTEXT.md | 3 +++ docs/adr/0001-settings-redesign.md | 11 +++++++++++ 2 files changed, 14 insertions(+) create mode 100644 docs/adr/0001-settings-redesign.md diff --git a/CONTEXT.md b/CONTEXT.md index 5a899655..20d5f344 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,4 +27,7 @@ Glossary of terms used in the BalatroBot project. | **pack** / **booster** / **booster pack** | Same thing. A purchasable pack of cards you open and choose from. | | **test fixture** | A JSON file of API call sequences that reproduces a specific game state. Not a pytest fixture. Generated by `make fixtures` and loaded by tests. | | **`dev` marker** | `@pytest.mark.dev` — tags tests currently being developed. Run with `pytest -m dev` to isolate. Remove when done. Ephemeral, not permanent. | +| **profile** | Overloaded by design — meaning is clear from context. Can refer to: (1) a **Balatro in-game profile** (save slot 1–3 with a name like "BalatroBot"), or (2) a **balatrosettings profile** (a directory of Lua files that overrides `G.SETTINGS` and `G.PROFILES` via `--settings`). | +| **BalatroBot profile** | A Balatro in-game profile named exactly `BalatroBot`. Required for the mod to activate — if no profile with this name exists, the HTTP server does not start and no settings overrides are applied. | +| **render mode** | How BalatroBot handles rendering: `headfull` (normal rendering, default), `headless` (no rendering, no window), or `ondemand` (render only when triggered by an API call). Set via `--render` or `BALATROBOT_RENDER`. | | **endpoint** | A single API operation (e.g. `play`, `start`, `health`). Each endpoint is a Lua module in `src/lua/endpoints/`. Called "method" in JSON-RPC contexts and exposed as `balatrobot api <endpoint>` in the CLI. | diff --git a/docs/adr/0001-settings-redesign.md b/docs/adr/0001-settings-redesign.md new file mode 100644 index 00000000..c211994f --- /dev/null +++ b/docs/adr/0001-settings-redesign.md @@ -0,0 +1,11 @@ +# Settings via balatrosettings profiles instead of individual CLI flags + +BalatroBot v2 replaces 12+ individual CLI flags/env vars for game settings (`--fast`, `--gamespeed`, `--fps-cap`, `--animation-fps`, `--audio`, `--no-reduced-motion`, `--pixel-art-smoothing`, etc.) with a single `--settings` flag that points to a balatrosettings profile directory. The mod is gated on the in-game profile being named exactly "BalatroBot" — if no such profile exists, the HTTP server does not start and no overrides are applied. Render modes are consolidated into a single `--render [headfull|headless|ondemand]` enum. Only two hardcoded overrides remain: `G.F_SKIP_TUTORIAL = true` and `all_unlocked = true`, applied only when the "BalatroBot" profile is detected. + +**Considered Options:** + +- **Individual flags** (v1 approach): flexible and composable, but the surface grew to 19 flags. Most were thin wrappers around `G.SETTINGS` fields that Balatro already has a mechanism for. Maintenance burden grew with every new setting. +- **balatrosettings profile** (chosen): reuses the existing balatrosettings format (plain `return {...}` Lua files). A profile is a directory with `settings.lua` and `1/profile.lua`, deep-merged into `G.SETTINGS` and `G.PROFILES`. Simpler CLI surface (one flag), and profiles are shareable across users. +- **Sidecar Lua file in profile dir**: a `balatrobot.lua` alongside `settings.lua` that could patch `G` globals directly. Rejected — arbitrary Lua execution defeats the simplicity goal and is hard to validate. + +**Why the "BalatroBot" profile gate:** BalatroBot needs `all_unlocked = true` and tutorial skipped to function as a bot platform. These can't be profile settings because they affect meta state that's consumed before SMODS loads (see boot sequence: `init_item_prototypes` runs at step 7, SMODS at step 8). Rather than hooking earlier via Lovely patches, we gate on the in-game profile name — the user creates a dedicated "BalatroBot" profile, and the mod only activates when that profile is selected. This protects the user's real save data (no accidental overwrites) and makes the activation condition visible in the game's own UI. From e0944f5084a3aebf1a68a7dfcabb5902002fb969 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 15:24:36 +0200 Subject: [PATCH 065/121] fix(load): wait for STOP_USE to clear before declaring load complete The load endpoint's completion check only waited for STATE_COMPLETE and CONTROLLER.locked, but not STOP_USE. At high GAMESPEED values, the game's internal state machine can briefly re-increment STOP_USE after the load appears done, causing subsequent endpoints (e.g. use) to fail with NOT_ALLOWED. --- src/lua/endpoints/load.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 437477b0..7f532550 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -123,7 +123,7 @@ return { func = function() local done = false - if not G.STATE_COMPLETE or G.CONTROLLER.locked then + if not G.STATE_COMPLETE or G.CONTROLLER.locked or (G.GAME.STOP_USE and G.GAME.STOP_USE > 0) then return false end From 4a393348553037c065e0b83a7ea69a98c6cdaa7c Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 15:27:19 +0200 Subject: [PATCH 066/121] refactor(config): replace individual CLI flags with settings profile and render mode Replace the collection of boolean/numeric flags (--fast, --headless, --render-on-api, --audio, --no-shaders, --fps-cap, --gamespeed, --animation-fps, etc.) with a unified --settings flag pointing to a balatrosettings profile directory and a --render flag accepting headfull|headless|ondemand. Rename path fields to --path-balatro, --path-lovely, --path-love, --path-logs with BALATROBOT_PATH_* env vars. Rename --num-instances to --num. This aligns CLI configuration with the profile-based approach decided in ADR 0001, reducing surface area and delegating game-tuning to external profile files. --- src/balatrobot/cli/serve.py | 98 ++++++++++++----------------- src/balatrobot/config.py | 69 +++++++------------- src/balatrobot/instance.py | 2 +- src/balatrobot/platforms/base.py | 2 +- src/balatrobot/platforms/linux.py | 38 +++++------ src/balatrobot/platforms/macos.py | 20 +++--- src/balatrobot/platforms/native.py | 36 +++++------ src/balatrobot/platforms/windows.py | 16 ++--- 8 files changed, 121 insertions(+), 160 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index aa7c25ed..f6a4b7b3 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -9,7 +9,7 @@ import typer -from balatrobot.config import Config +from balatrobot.config import RENDER_CHOICES, Config from balatrobot.instance import InstanceDiedError from balatrobot.pool import BalatroPool from balatrobot.state import StateFile, StateFileBusy, default_state_path @@ -92,51 +92,37 @@ async def run(self) -> None: def serve( # fmt: off - num_instances: Annotated[ - int, - typer.Option( - "-n", "--num-instances", help="Number of instances to start (default: 1)" - ), + num: Annotated[ + int, typer.Option("--num", help="Number of instances to start (default: 1)") ] = 1, - fps_cap: Annotated[ - int | None, typer.Option(help="Maximum FPS cap (default: 60)") + settings: Annotated[ + str | None, + typer.Option("--settings", help="Path to balatrosettings profile directory"), ] = None, - gamespeed: Annotated[ - int | None, typer.Option(help="Game speed multiplier (default: 4)") + render: Annotated[ + str | None, + typer.Option("--render", help="Render mode: headfull|headless|ondemand"), ] = None, - animation_fps: Annotated[ - int | None, typer.Option(help="Animation FPS (default: 10)") + debug: Annotated[ + bool | None, typer.Option("--debug", help="Enable debug endpoints") ] = None, - logs_path: Annotated[ - str | None, typer.Option(help="Directory for log files (default: logs)") + host: Annotated[str | None, typer.Option("--host", help="Server hostname")] = None, + port: Annotated[int | None, typer.Option("--port", help="Server port")] = None, + path_balatro: Annotated[ + str | None, typer.Option("--path-balatro", help="Path to Balatro directory") ] = None, - fast: Annotated[ - bool | None, typer.Option(help="Enable fast mode (10x speed)") + path_lovely: Annotated[ + str | None, typer.Option("--path-lovely", help="Path to lovely library") ] = None, - headless: Annotated[bool | None, typer.Option(help="Enable headless mode")] = None, - render_on_api: Annotated[ - bool | None, typer.Option(help="Render only on API calls") - ] = None, - audio: Annotated[bool | None, typer.Option(help="Enable audio")] = None, - debug: Annotated[bool | None, typer.Option(help="Enable debug mode")] = None, - no_shaders: Annotated[bool | None, typer.Option(help="Disable shaders")] = None, - no_reduced_motion: Annotated[ - bool | None, typer.Option(help="Disable reduced motion") - ] = None, - pixel_art_smoothing: Annotated[ - bool | None, typer.Option(help="Enable pixel art smoothing") - ] = None, - balatro_path: Annotated[ - str | None, typer.Option(help="Path to Balatro executable") - ] = None, - lovely_path: Annotated[ - str | None, typer.Option(help="Path to lovely library") - ] = None, - love_path: Annotated[ - str | None, typer.Option(help="Path to game launcher executable") + path_love: Annotated[ + str | None, typer.Option("--path-love", help="Path to LOVE executable") ] = None, platform: Annotated[ - str | None, typer.Option(help="Platform (darwin, linux, windows, native)") + str | None, + typer.Option("--platform", help="Platform (darwin, linux, windows, native)"), + ] = None, + path_logs: Annotated[ + str | None, typer.Option("--path-logs", help="Log directory") ] = None, # fmt: on ) -> None: @@ -150,36 +136,34 @@ def serve( ) raise typer.Exit(code=1) - # Validate num_instances - if num_instances < 1: + if render is not None and render not in RENDER_CHOICES: typer.echo( - f"Error: --num-instances must be >= 1, got {num_instances}.", + f"Error: Invalid render mode '{render}'. " + f"Choose from: {', '.join(sorted(RENDER_CHOICES))}", err=True, ) raise typer.Exit(code=1) + if num < 1: + typer.echo(f"Error: --num must be >= 1, got {num}.", err=True) + raise typer.Exit(code=1) + # Build config from kwargs with env var fallback config = Config.from_kwargs( - fps_cap=fps_cap, - gamespeed=gamespeed, - animation_fps=animation_fps, - logs_path=logs_path, - fast=fast, - headless=headless, - render_on_api=render_on_api, - audio=audio, + settings=settings, + render=render, debug=debug, - no_shaders=no_shaders, - no_reduced_motion=no_reduced_motion, - pixel_art_smoothing=pixel_art_smoothing, - balatro_path=balatro_path, - lovely_path=lovely_path, - love_path=love_path, + host=host, + port=port, + path_balatro=path_balatro, + path_lovely=path_lovely, + path_love=path_love, platform=platform, + path_logs=path_logs, ) try: - asyncio.run(_serve(config, num_instances)) + asyncio.run(_serve(config, num)) except KeyboardInterrupt: typer.echo("\nShutting down server...") except InstanceDiedError as e: @@ -197,7 +181,7 @@ async def _serve(config: Config, n: int) -> None: for i, info in enumerate(pool.instances): typer.echo(f"Instance [{i}]: {info.url}") typer.echo( - f"Session: {pool.session_name} | Logs: {config.logs_path}/{pool.session_name}/" + f"Session: {pool.session_name} | Logs: {config.path_logs}/{pool.session_name}/" ) typer.echo("Press Ctrl+C to stop.") await server.run() diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index c036d7e8..bd1993e5 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -4,41 +4,24 @@ from dataclasses import dataclass from typing import Any, Self -# Mapping: config field -> env var ENV_MAP: dict[str, str] = { "host": "BALATROBOT_HOST", "port": "BALATROBOT_PORT", - "fast": "BALATROBOT_FAST", - "headless": "BALATROBOT_HEADLESS", - "render_on_api": "BALATROBOT_RENDER_ON_API", - "audio": "BALATROBOT_AUDIO", + "render": "BALATROBOT_RENDER", "debug": "BALATROBOT_DEBUG", - "no_shaders": "BALATROBOT_NO_SHADERS", - "fps_cap": "BALATROBOT_FPS_CAP", - "gamespeed": "BALATROBOT_GAMESPEED", - "animation_fps": "BALATROBOT_ANIMATION_FPS", - "no_reduced_motion": "BALATROBOT_NO_REDUCED_MOTION", - "pixel_art_smoothing": "BALATROBOT_PIXEL_ART_SMOOTHING", - "balatro_path": "BALATROBOT_BALATRO_PATH", - "lovely_path": "BALATROBOT_LOVELY_PATH", - "love_path": "BALATROBOT_LOVE_PATH", + "settings": "BALATROBOT_SETTINGS", + "path_balatro": "BALATROBOT_PATH_BALATRO", + "path_lovely": "BALATROBOT_PATH_LOVELY", + "path_love": "BALATROBOT_PATH_LOVE", "platform": "BALATROBOT_PLATFORM", - "logs_path": "BALATROBOT_LOGS_PATH", + "path_logs": "BALATROBOT_PATH_LOGS", } -BOOL_FIELDS = frozenset( - { - "fast", - "headless", - "render_on_api", - "audio", - "debug", - "no_shaders", - "no_reduced_motion", - "pixel_art_smoothing", - } -) -INT_FIELDS = frozenset({"port", "fps_cap", "gamespeed", "animation_fps"}) +BOOL_FIELDS = frozenset({"debug"}) + +INT_FIELDS = frozenset({"port"}) + +RENDER_CHOICES = frozenset({"headfull", "headless", "ondemand"}) def _parse_env_value(field: str, value: str) -> str | int | bool: @@ -46,7 +29,7 @@ def _parse_env_value(field: str, value: str) -> str | int | bool: if field in BOOL_FIELDS: return value in ("1", "true") if field in INT_FIELDS: - return int(value) # Raises ValueError if invalid + return int(value) return value @@ -58,27 +41,21 @@ class Config: host: str = "127.0.0.1" port: int = 12346 - # Balatro - fast: bool = False - headless: bool = False - render_on_api: bool = False - audio: bool = False + # Settings profile + settings: str | None = None + + # Render mode + render: str = "headfull" + + # Debug debug: bool = False - no_shaders: bool = False - fps_cap: int = 60 - gamespeed: int = 4 - animation_fps: int = 10 - no_reduced_motion: bool = False - pixel_art_smoothing: bool = False # Launcher - balatro_path: str | None = None - lovely_path: str | None = None - love_path: str | None = None - - # Instance + path_balatro: str | None = None + path_lovely: str | None = None + path_love: str | None = None platform: str | None = None - logs_path: str = "logs" + path_logs: str = "logs" @classmethod def from_args(cls, args) -> Self: diff --git a/src/balatrobot/instance.py b/src/balatrobot/instance.py index fddff0e0..170889db 100644 --- a/src/balatrobot/instance.py +++ b/src/balatrobot/instance.py @@ -110,7 +110,7 @@ async def start(self) -> None: session_name = self._session_name or datetime.now().strftime( "%Y-%m-%dT%H-%M-%S" ) - session_dir = Path(self._config.logs_path) / session_name + session_dir = Path(self._config.path_logs) / session_name session_dir.mkdir(parents=True, exist_ok=True) self._log_path = session_dir / f"{self._config.port}.log" diff --git a/src/balatrobot/platforms/base.py b/src/balatrobot/platforms/base.py index 053c00ff..f0bd87e6 100644 --- a/src/balatrobot/platforms/base.py +++ b/src/balatrobot/platforms/base.py @@ -54,7 +54,7 @@ async def start(self, config: Config, session_dir: Path) -> subprocess.Popen: """ self.validate_paths(config) env = self.build_env(config) - env["BALATROBOT_LOGS_PATH"] = str(session_dir.resolve()) + env["BALATROBOT_PATH_LOGS"] = str(session_dir.resolve()) cmd = self.build_cmd(config) log_path = session_dir / f"{config.port}.log" diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py index 66a98185..8654eddb 100644 --- a/src/balatrobot/platforms/linux.py +++ b/src/balatrobot/platforms/linux.py @@ -61,42 +61,42 @@ def validate_paths(self, config: Config) -> None: ) # Balatro game directory - if config.balatro_path is None: + if config.path_balatro is None: candidate = steam_root / "steamapps/common/Balatro" if candidate.is_dir(): - config.balatro_path = str(candidate) + config.path_balatro = str(candidate) - if config.balatro_path is None: + if config.path_balatro is None: raise RuntimeError( "Balatro game directory not found under Steam root. " - "Set --balatro-path or BALATROBOT_BALATRO_PATH." + "Set --path-balatro or BALATROBOT_PATH_BALATRO." ) - balatro = Path(config.balatro_path) + balatro = Path(config.path_balatro) if not balatro.is_dir() or not (balatro / "Balatro.exe").is_file(): raise RuntimeError(f"Balatro game directory not found: {balatro}") # Lovely (version.dll) - if config.lovely_path is None: + if config.path_lovely is None: candidate = balatro / "version.dll" if candidate.is_file(): - config.lovely_path = str(candidate) + config.path_lovely = str(candidate) - if config.lovely_path is None: + if config.path_lovely is None: raise RuntimeError( "lovely-injector version.dll not found. " - "Set --lovely-path or BALATROBOT_LOVELY_PATH." + "Set --path-lovely or BALATROBOT_PATH_LOVELY." ) # Proton executable - if config.love_path is None: + if config.path_love is None: detected = _detect_proton_path(steam_root) if detected: - config.love_path = str(detected) + config.path_love = str(detected) - if config.love_path is None: + if config.path_love is None: raise RuntimeError( - "Proton executable not found. Set --love-path or BALATROBOT_LOVE_PATH." + "Proton executable not found. Set --path-love or BALATROBOT_PATH_LOVE." ) def build_env(self, config: Config) -> dict[str, str]: @@ -118,10 +118,10 @@ def build_env(self, config: Config) -> dict[str, str]: def build_cmd(self, config: Config) -> list[str]: """Build Linux launch command via Proton.""" - assert config.love_path is not None - assert config.balatro_path is not None - balatro_exe = str(Path(config.balatro_path) / "Balatro.exe") - return [config.love_path, "run", balatro_exe] + assert config.path_love is not None + assert config.path_balatro is not None + balatro_exe = str(Path(config.path_balatro) / "Balatro.exe") + return [config.path_love, "run", balatro_exe] def cleanup(self, config: Config) -> None: """Shut down the Wine prefix via wineserver -k. @@ -131,11 +131,11 @@ def cleanup(self, config: Config) -> None: wineserver -k cleanly terminates all Wine processes and closes display connections so the compositor removes windows. """ - if config.love_path is None: + if config.path_love is None: return # wineserver lives next to the proton script - proton_dir = Path(config.love_path).parent + proton_dir = Path(config.path_love).parent wineserver = proton_dir / "files" / "bin" / "wineserver" if not wineserver.is_file(): return diff --git a/src/balatrobot/platforms/macos.py b/src/balatrobot/platforms/macos.py index ec67d00a..9ad5f6f0 100644 --- a/src/balatrobot/platforms/macos.py +++ b/src/balatrobot/platforms/macos.py @@ -12,21 +12,21 @@ class MacOSLauncher(BaseLauncher): def validate_paths(self, config: Config) -> None: """Validate paths, apply macOS defaults if None.""" - if config.love_path is None: - config.love_path = str( + if config.path_love is None: + config.path_love = str( Path.home() / "Library/Application Support/Steam/steamapps/common/Balatro" / "Balatro.app/Contents/MacOS/love" ) - if config.lovely_path is None: - config.lovely_path = str( + if config.path_lovely is None: + config.path_lovely = str( Path.home() / "Library/Application Support/Steam/steamapps/common/Balatro" / "liblovely.dylib" ) - love = Path(config.love_path) - lovely = Path(config.lovely_path) + love = Path(config.path_love) + lovely = Path(config.path_lovely) if not love.exists(): raise RuntimeError(f"LOVE executable not found: {love}") @@ -35,13 +35,13 @@ def validate_paths(self, config: Config) -> None: def build_env(self, config: Config) -> dict[str, str]: """Build environment with DYLD_INSERT_LIBRARIES.""" - assert config.lovely_path is not None + assert config.path_lovely is not None env = os.environ.copy() - env["DYLD_INSERT_LIBRARIES"] = config.lovely_path + env["DYLD_INSERT_LIBRARIES"] = config.path_lovely env.update(config.to_env()) return env def build_cmd(self, config: Config) -> list[str]: """Build macOS launch command.""" - assert config.love_path is not None - return [config.love_path] + assert config.path_love is not None + return [config.path_love] diff --git a/src/balatrobot/platforms/native.py b/src/balatrobot/platforms/native.py index ecc84653..0e8b4985 100644 --- a/src/balatrobot/platforms/native.py +++ b/src/balatrobot/platforms/native.py @@ -49,47 +49,47 @@ def validate_paths(self, config: Config) -> None: errors: list[str] = [] # balatro_path (required, no auto-detect) - if config.balatro_path is None: + if config.path_balatro is None: errors.append( "Game directory is required.\n" - " Set via: --balatro-path or BALATROBOT_BALATRO_PATH" + " Set via: --path-balatro or BALATROBOT_PATH_BALATRO" ) else: - balatro = Path(config.balatro_path) + balatro = Path(config.path_balatro) if not balatro.is_dir(): errors.append(f"Game directory not found: {balatro}") # lovely_path (required, auto-detect) - if config.lovely_path is None: + if config.path_lovely is None: detected = _detect_lovely_path() if detected: - config.lovely_path = str(detected) + config.path_lovely = str(detected) else: errors.append( "Lovely library is required.\n" - " Set via: --lovely-path or BALATROBOT_LOVELY_PATH\n" + " Set via: --path-lovely or BALATROBOT_PATH_LOVELY\n" " Expected: /usr/local/lib/liblovely.so" ) - if config.lovely_path: - lovely = Path(config.lovely_path) + if config.path_lovely: + lovely = Path(config.path_lovely) if not lovely.is_file(): errors.append(f"Lovely library not found: {lovely}") elif lovely.suffix != ".so": errors.append(f"Lovely library has wrong extension: {lovely}") # love_path (required, auto-detect via PATH) - if config.love_path is None: + if config.path_love is None: detected = _detect_love_path() if detected: - config.love_path = str(detected) + config.path_love = str(detected) else: errors.append( "LOVE executable is required.\n" - " Set via: --love-path or BALATROBOT_LOVE_PATH\n" + " Set via: --path-love or BALATROBOT_PATH_LOVE\n" " Or install love and ensure it's in PATH" ) - if config.love_path: - love = Path(config.love_path) + if config.path_love: + love = Path(config.path_love) if not love.is_file(): errors.append(f"LOVE executable not found: {love}") @@ -98,14 +98,14 @@ def validate_paths(self, config: Config) -> None: def build_env(self, config: Config) -> dict[str, str]: """Build environment with LD_PRELOAD.""" - assert config.lovely_path is not None + assert config.path_lovely is not None env = os.environ.copy() - env["LD_PRELOAD"] = config.lovely_path + env["LD_PRELOAD"] = config.path_lovely env.update(config.to_env()) return env def build_cmd(self, config: Config) -> list[str]: """Build native LOVE launch command.""" - assert config.love_path is not None - assert config.balatro_path is not None - return [config.love_path, config.balatro_path] + assert config.path_love is not None + assert config.path_balatro is not None + return [config.path_love, config.path_balatro] diff --git a/src/balatrobot/platforms/windows.py b/src/balatrobot/platforms/windows.py index f1583f9c..02ea336d 100644 --- a/src/balatrobot/platforms/windows.py +++ b/src/balatrobot/platforms/windows.py @@ -12,17 +12,17 @@ class WindowsLauncher(BaseLauncher): def validate_paths(self, config: Config) -> None: """Validate paths, apply Windows defaults if None.""" - if config.love_path is None: - config.love_path = ( + if config.path_love is None: + config.path_love = ( r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe" ) - if config.lovely_path is None: - config.lovely_path = ( + if config.path_lovely is None: + config.path_lovely = ( r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll" ) - love = Path(config.love_path) - lovely = Path(config.lovely_path) + love = Path(config.path_love) + lovely = Path(config.path_lovely) if not love.exists(): raise RuntimeError(f"Balatro executable not found: {love}") @@ -37,5 +37,5 @@ def build_env(self, config: Config) -> dict[str, str]: def build_cmd(self, config: Config) -> list[str]: """Build Windows launch command.""" - assert config.love_path is not None - return [config.love_path] + assert config.path_love is not None + return [config.path_love] From 6f864c02ed3f06e0bb0c767c51ccc110ac0e68e7 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 15:27:29 +0200 Subject: [PATCH 067/121] refactor(settings): profile-based Lua settings with BalatroBot profile gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite settings.lua to use balatrosettings profiles instead of individual env-var flags. The mod now gates activation on the in-game profile being named "BalatroBot" — if not, setup() returns false and the API server does not start. Supports three render modes (headfull, headless, ondemand) via BALATROBOT_RENDER. Deep-merges profile files into G.SETTINGS and G.PROFILES. Remove all per-flag env vars (HEADLESS, FAST, RENDER_ON_API, AUDIO, NO_SHADERS, etc.). Update types.lua Settings class to reflect the new fields. Gate balatrobot.lua on setup() return value. --- balatrobot.lua | 4 +- src/lua/settings.lua | 297 ++++++++++++---------------------------- src/lua/utils/types.lua | 14 +- 3 files changed, 94 insertions(+), 221 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 90b3a54f..e09ccece 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -2,7 +2,9 @@ assert(SMODS.load_file("src/lua/settings.lua"))() -- define BB_SETTINGS -- Configure Balatro with appropriate settings from environment variables -BB_SETTINGS.setup() +if not BB_SETTINGS.setup() then + return +end -- Endpoints for the BalatroBot API BB_ENDPOINTS = { diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 9ea0ba8e..6cf7809c 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -1,44 +1,18 @@ --[[ -BalatroBot configure settings in Balatro using the following environment variables: - - - BALATROBOT_HOST: the hostname when the TCP server is running. - Type string (default: 127.0.0.1) - - - BALATROBOT_PORT: the port when the TCP server is running. - Type string (default: 12346) - - - BALATROBOT_HEADLESS: whether to run in headless mode. - 1 for actiavate the headeless mode, 0 for running headed (default: 0) - - - BALATROBOT_FAST: whether to run in fast mode. - 1 for actiavate the fast mode, 0 for running slow (default: 0) - - - BALATROBOT_RENDER_ON_API: whether to render frames only on API calls. - 1 for actiavate the render on API mode, 0 for normal rendering (default: 0) - - - BALATROBOT_AUDIO: whether to play audio. - 1 for actiavate the audio mode, 0 for no audio (default: 0) - - - BALATROBOT_DEBUG: whether enable debug mode. It requires DebugPlus mod to be running. - 1 for actiavate the debug mode, 0 for no debug (default: 0) - - - BALATROBOT_NO_SHADERS: whether to disable all shaders for better performance. - 1 for disable shaders, 0 for enable shaders (default: 0) - - - BALATROBOT_FPS_CAP: the maximum FPS cap for the game. - Type number (default: 60) - - - BALATROBOT_GAMESPEED: the game speed multiplier. - Type number (default: 4) - - - BALATROBOT_ANIMATION_FPS: the animation FPS. - Type number (default: 10) - - - BALATROBOT_NO_REDUCED_MOTION: whether to disable reduced motion. - 1 for disable reduced motion, 0 for enable reduced motion (default: 0) - - - BALATROBOT_PIXEL_ART_SMOOTHING: whether to enable pixel art smoothing. - 1 for enable pixel art smoothing, 0 for disable (default: 0) +BalatroBot v2 settings — profile-based configuration. + +Environment variables: + BALATROBOT_HOST - Server hostname (default: 127.0.0.1) + BALATROBOT_PORT - Server port (default: 12346) + BALATROBOT_RENDER - Render mode: headfull|headless|ondemand (default: headfull) + BALATROBOT_DEBUG - Enable debug endpoints (1/0, default: 0) + BALATROBOT_SETTINGS - Path to balatrosettings profile directory + BALATROBOT_PATH_BALATRO - Path to Balatro directory + BALATROBOT_PATH_LOVELY - Path to lovely library + BALATROBOT_PATH_LOVE - Path to LOVE executable + BALATROBOT_PLATFORM - Platform override + BALATROBOT_PATH_LOGS - Log directory + BALATROBOT_NUM - Number of instances ]] ---@diagnostic disable: duplicate-set-field @@ -47,242 +21,147 @@ BalatroBot configure settings in Balatro using the following environment variabl BB_SETTINGS = { host = os.getenv("BALATROBOT_HOST") or "127.0.0.1", port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, - headless = os.getenv("BALATROBOT_HEADLESS") == "1" or false, - fast = os.getenv("BALATROBOT_FAST") == "1" or false, - render_on_api = os.getenv("BALATROBOT_RENDER_ON_API") == "1" or false, - audio = os.getenv("BALATROBOT_AUDIO") == "1" or false, + render = os.getenv("BALATROBOT_RENDER") or "headfull", debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, - no_shaders = os.getenv("BALATROBOT_NO_SHADERS") == "1" or false, - fps_cap = tonumber(os.getenv("BALATROBOT_FPS_CAP")) or 60, - gamespeed = tonumber(os.getenv("BALATROBOT_GAMESPEED")) or 4, - animation_fps = tonumber(os.getenv("BALATROBOT_ANIMATION_FPS")) or 10, - no_reduced_motion = os.getenv("BALATROBOT_NO_REDUCED_MOTION") == "1" or false, - pixel_art_smoothing = os.getenv("BALATROBOT_PIXEL_ART_SMOOTHING") == "1" or false, + settings_path = os.getenv("BALATROBOT_SETTINGS"), } ---@type boolean? BB_RENDER = nil ---- Patches love.update to use a fixed delta time based on headless mode ---- Headless mode uses 4.99/60 for faster simulation, normal mode uses 1/60 ----@return nil -local function configure_love_update() - local love_update = love.update - local dt = BB_SETTINGS.headless and (4.99 / 60.0) or (1.0 / 60.0) - love.update = function(_) - love_update(dt) +--- Deep merge source into target table (recursive) +---@param target table +---@param source table +local function deep_merge(target, source) + for k, v in pairs(source) do + if type(v) == "table" and type(target[k]) == "table" then + deep_merge(target[k], v) + else + target[k] = v + end end - sendDebugMessage("Patched love.update with dt=" .. dt, "BB.SETTINGS") end ---- Configures base game settings for optimal bot performance ---- Disables audio, sets high game speed, reduces visual effects, and disables tutorials ----@return nil -local function configure_settings() - -- disable audio - G.SETTINGS.SOUND.volume = 0 - G.SETTINGS.SOUND.music_volume = 0 - G.SETTINGS.SOUND.game_sounds_volume = 0 - G.F_SOUND_THREAD = false - G.F_MUTE = true - - -- performance - G.FPS_CAP = BB_SETTINGS.fps_cap - G.SETTINGS.GAMESPEED = BB_SETTINGS.gamespeed - G.ANIMATION_FPS = BB_SETTINGS.animation_fps - - -- features - G.F_SKIP_TUTORIAL = true - G.VIBRATION = 0 - G.F_VERBOSE = true - G.F_RUMBLE = nil - - -- graphics - G.SETTINGS.GRAPHICS = G.SETTINGS.GRAPHICS or {} - G.SETTINGS.GRAPHICS.shadows = "Off" -- Always disable shadows - G.SETTINGS.GRAPHICS.bloom = 0 -- Always disable CRT bloom - G.SETTINGS.GRAPHICS.crt = 0 -- Always disable CRT - G.SETTINGS.GRAPHICS.texture_scaling = BB_SETTINGS.pixel_art_smoothing and 2 or 1 - - -- visuals - G.SETTINGS.skip_splash = "Yes" -- Skip intro animation - G.SETTINGS.reduced_motion = not BB_SETTINGS.no_reduced_motion - G.SETTINGS.screenshake = false - G.SETTINGS.rumble = nil - - -- Window - love.window.setVSync(0) - G.SETTINGS.WINDOW = G.SETTINGS.WINDOW or {} - G.SETTINGS.WINDOW.vsync = 0 +--- Apply balatrosettings profile from directory +---@param path string Absolute path to profile directory +local function apply_profile(path) + local NFS = require("nativefs") + + -- Deep merge settings.lua into G.SETTINGS + local settings_src = NFS.read(path .. "/settings.lua") + assert(settings_src, "Profile not found: " .. path .. "/settings.lua") + local profile_settings = assert(load(settings_src))() + assert(type(profile_settings) == "table", "settings.lua must return a table") + deep_merge(G.SETTINGS, profile_settings) + + -- Deep merge 1/profile.lua into G.PROFILES[n] + local profile_src = NFS.read(path .. "/1/profile.lua") + assert(profile_src, "Profile not found: " .. path .. "/1/profile.lua") + local profile_data = assert(load(profile_src))() + assert(type(profile_data) == "table", "1/profile.lua must return a table") + local n = G.SETTINGS.profile or 1 + G.PROFILES[n] = G.PROFILES[n] or {} + deep_merge(G.PROFILES[n], profile_data) + + sendInfoMessage("Applied profile: " .. path, "BB.SETTINGS") end ---- Configures headless mode by minimizing and hiding the window ---- Disables all rendering operations, graphics, and window updates ----@return nil +--- Headless mode: disable all rendering and window operations local function configure_headless() if love.window and love.window.isOpen() then if love.window.minimize then love.window.minimize() - sendDebugMessage("Minimized window", "BB.SETTINGS") end - love.window.setMode(1, 1) love.window.setPosition(-1000, -1000) - sendDebugMessage("Set window to 1x1 and moved to (-1000, -1000)", "BB.SETTINGS") end - -- Disable all rendering operations love.graphics.isActive = function() return false end + love.draw = function() end + love.graphics.present = function() end - -- Disable drawing operations - love.draw = function() - -- Do nothing in headless mode - end - - -- Disable graphics present/swap buffers - love.graphics.present = function() - -- Do nothing in headless mode - end - - -- Disable window creation/updates for future calls if love.window then love.window.setMode = function() return false end - love.window.isOpen = function() return false end - - love.window.setPosition = function() - -- Do nothing - end - - love.window.minimize = function() - -- Do nothing - end - - love.window.maximize = function() - -- Do nothing - end - - love.window.restore = function() - -- Do nothing - end - - love.window.requestAttention = function() - -- Do nothing - end - + love.window.setPosition = function() end + love.window.minimize = function() end + love.window.maximize = function() end + love.window.restore = function() end + love.window.requestAttention = function() end love.window.setFullscreen = function() return false end - love.graphics.isCreated = function() return false end end - sendInfoMessage("Headless mode enabled", "BB.SETTINGS") + sendInfoMessage("Render mode: headless", "BB.SETTINGS") end ---- Configures render-on-API mode where frames are only rendered when BB_RENDER is true ---- Patches love.draw and love.graphics.present to conditionally render based on BB_RENDER flag ----@return nil -local function configure_render_on_api() +--- On-demand rendering: only render when BB_RENDER is set +local function configure_ondemand() BB_RENDER = false - -- Original render function local love_draw = love.draw local love_graphics_present = love.graphics.present - - local did_render_this_frame = false + local did_render = false love.draw = function() if BB_RENDER then love_draw() - did_render_this_frame = true + did_render = true BB_RENDER = false else - did_render_this_frame = false + did_render = false end end love.graphics.present = function() - if did_render_this_frame then + if did_render then love_graphics_present() - did_render_this_frame = false + did_render = false end end - sendInfoMessage("Render on API mode enabled", "BB.SETTINGS") -end - ---- Configures fast mode with unlimited FPS, 10x game speed, and 60 FPS animations ----@return nil -local function configure_fast() - -- performance - G.FPS_CAP = nil -- Unlimited FPS - G.SETTINGS.GAMESPEED = 10 -- 10x game speed - G.ANIMATION_FPS = 60 -- 6x faster animations - G.F_VERBOSE = false + sendInfoMessage("Render mode: ondemand", "BB.SETTINGS") end ---- Disables all shaders by overriding love.graphics.setShader to always pass nil ---- This improves performance by bypassing shader compilation and rendering ---- Disabling shaders cause visual glitches. Use at your own risk. ----@return nil -local function configure_no_shaders() - local love_graphics_setShader = love.graphics.setShader - love.graphics.setShader = function() - return love_graphics_setShader() - end - sendInfoMessage("Disabled all shaders", "BB.SETTINGS") -end - ---- Enables audio by setting volume levels and enabling sound thread ----@return nil -local function configure_audio() - G.SETTINGS.SOUND = G.SETTINGS.SOUND or {} - G.SETTINGS.SOUND.volume = 50 - G.SETTINGS.SOUND.music_volume = 100 - G.SETTINGS.SOUND.game_sounds_volume = 100 - G.F_MUTE = false - G.F_SOUND_THREAD = true -end - ---- Initializes and applies all BalatroBot settings based on environment variables ---- Orchestrates configuration of love.update, game settings, and optional features ---- (headless, render-on-api, fast mode, audio) ----@return nil +--- Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. +---@return boolean BB_SETTINGS.setup = function() - configure_love_update() - configure_settings() - - if BB_SETTINGS.headless and BB_SETTINGS.render_on_api then - sendWarnMessage("Headless mode and render on API mode are mutually exclusive. Disabling headless", "BB.SETTINGS") - BB_SETTINGS.headless = false - end - - if BB_SETTINGS.headless then - configure_headless() + -- Gate: only activate when in-game profile is named "BalatroBot" + local profile_num = G.SETTINGS.profile or 1 + local profile = G.PROFILES[profile_num] + if not profile or profile.name ~= "BalatroBot" then + sendWarnMessage( + "BalatroBot profile not selected. Create a profile named 'BalatroBot' and select it.", + "BB.SETTINGS" + ) + return false end - if BB_SETTINGS.render_on_api then - configure_render_on_api() - end + -- Hardcoded overrides for bot operation + G.F_SKIP_TUTORIAL = true + G.PROFILES[profile_num].all_unlocked = true - if BB_SETTINGS.fast then - configure_fast() + -- Apply settings profile if --settings provided + if BB_SETTINGS.settings_path then + apply_profile(BB_SETTINGS.settings_path) end - if BB_SETTINGS.no_shaders then - configure_no_shaders() + -- Render mode + if BB_SETTINGS.render == "headless" then + configure_headless() + elseif BB_SETTINGS.render == "ondemand" then + configure_ondemand() end - if BB_SETTINGS.audio then - configure_audio() - end + return true end diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index fa357780..1ebfabfe 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -275,18 +275,10 @@ ---@class Settings ---@field host string Hostname for the HTTP server (default: "127.0.0.1") ---@field port integer Port number for the HTTP server (default: 12346) ----@field headless boolean Whether to run in headless mode (minimizes window, disables rendering) ----@field fast boolean Whether to run in fast mode (unlimited FPS, 10x game speed, 60 FPS animations) ----@field render_on_api boolean Whether to render frames only on API calls (mutually exclusive with headless) ----@field audio boolean Whether to play audio (enables sound thread and sets volume levels) +---@field render string Render mode: headfull|headless|ondemand (default: "headfull") ---@field debug boolean Whether debug mode is enabled (requires DebugPlus mod) ----@field no_shaders boolean Whether to disable all shaders for better performance (causes visual glitches) ----@field fps_cap integer Maximum FPS cap for the game (default: 60) ----@field gamespeed integer Game speed multiplier (default: 4) ----@field animation_fps integer Animation FPS (default: 10) ----@field no_reduced_motion boolean Whether to disable reduced motion for faster animations ----@field pixel_art_smoothing boolean Whether to enable pixel art smoothing (texture_scaling = 2) ----@field setup fun()? Initialize and apply all BalatroBot settings +---@field settings_path string? Path to balatrosettings profile directory (nil if not provided) +---@field setup fun(): boolean Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. ---@class Debug ---@field log table? DebugPlus logger instance with debug/info/error methods (nil if DebugPlus not available) From b5293a06c37499a6ffd616bd4c2fb3c5cf4a3561 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 15:27:39 +0200 Subject: [PATCH 068/121] test(config): update tests for settings profile and render mode API Adapt all CLI tests to the new Config API: replace fast/headless/ render_on_api/audio with render mode and settings path, update path field names (path_balatro, path_lovely, path_love, path_logs), and add tests for RENDER_CHOICES validation, settings CLI/env paths, and new --num flag name. Remove tests for deleted flags. --- tests/cli/test_config.py | 193 +++++++++++++++++++++++++--------- tests/cli/test_instance.py | 6 +- tests/cli/test_integration.py | 4 +- tests/cli/test_platforms.py | 40 +++---- tests/cli/test_pool.py | 20 ++-- tests/cli/test_serve_cmd.py | 61 ++++++++--- tests/cli/test_server.py | 18 ++-- 7 files changed, 232 insertions(+), 110 deletions(-) diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index a27acf7b..310dbdb3 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -4,7 +4,7 @@ import pytest -from balatrobot.config import Config, _parse_env_value +from balatrobot.config import RENDER_CHOICES, Config, _parse_env_value class TestParseEnvValue: @@ -12,15 +12,14 @@ class TestParseEnvValue: def test_bool_true_values(self): """Boolean fields convert '1' and 'true' to True.""" - assert _parse_env_value("fast", "1") is True - assert _parse_env_value("fast", "true") is True - assert _parse_env_value("headless", "1") is True + assert _parse_env_value("debug", "1") is True + assert _parse_env_value("debug", "true") is True def test_bool_false_values(self): """Boolean fields convert other values to False.""" - assert _parse_env_value("fast", "0") is False - assert _parse_env_value("fast", "false") is False - assert _parse_env_value("fast", "yes") is False + assert _parse_env_value("debug", "0") is False + assert _parse_env_value("debug", "false") is False + assert _parse_env_value("debug", "yes") is False def test_int_valid(self): """Integer fields parse valid numbers.""" @@ -35,7 +34,8 @@ def test_int_invalid(self): def test_string_passthrough(self): """String fields pass through unchanged.""" assert _parse_env_value("host", "localhost") == "localhost" - assert _parse_env_value("balatro_path", "/path/to/game") == "/path/to/game" + assert _parse_env_value("render", "headless") == "headless" + assert _parse_env_value("settings", "/path/to/profile") == "/path/to/profile" class TestConfigDefaults: @@ -47,10 +47,34 @@ def test_defaults(self, clean_env): assert config.host == "127.0.0.1" assert config.port == 12346 - assert config.fast is False - assert config.headless is False - assert config.logs_path == "logs" - assert config.balatro_path is None + assert config.render == "headfull" + assert config.debug is False + assert config.settings is None + assert config.path_logs == "logs" + assert config.path_balatro is None + assert config.path_lovely is None + assert config.path_love is None + assert config.platform is None + + +class TestRenderChoices: + """Tests for RENDER_CHOICES constant.""" + + def test_render_choices(self): + """RENDER_CHOICES contains the three valid modes.""" + assert RENDER_CHOICES == frozenset({"headfull", "headless", "ondemand"}) + + def test_headfull_in_choices(self): + """headfull is a valid render mode.""" + assert "headfull" in RENDER_CHOICES + + def test_headless_in_choices(self): + """headless is a valid render mode.""" + assert "headless" in RENDER_CHOICES + + def test_ondemand_in_choices(self): + """ondemand is a valid render mode.""" + assert "ondemand" in RENDER_CHOICES class TestConfigFromArgs: @@ -61,23 +85,20 @@ def test_cli_args_used(self, clean_env): args = Namespace( host="0.0.0.0", port=9999, - fast=True, - headless=None, - render_on_api=None, - audio=None, + render="headless", debug=None, - no_shaders=None, - balatro_path=None, - lovely_path=None, - love_path=None, + settings=None, + path_balatro=None, + path_lovely=None, + path_love=None, platform=None, - logs_path=None, + path_logs=None, ) config = Config.from_args(args) assert config.host == "0.0.0.0" assert config.port == 9999 - assert config.fast is True + assert config.render == "headless" def test_cli_overrides_env(self, clean_env, monkeypatch): """CLI args override environment variables.""" @@ -86,17 +107,14 @@ def test_cli_overrides_env(self, clean_env, monkeypatch): args = Namespace( host=None, port=9999, - fast=None, - headless=None, - render_on_api=None, - audio=None, + render=None, debug=None, - no_shaders=None, - balatro_path=None, - lovely_path=None, - love_path=None, + settings=None, + path_balatro=None, + path_lovely=None, + path_love=None, platform=None, - logs_path=None, + path_logs=None, ) config = Config.from_args(args) @@ -105,27 +123,42 @@ def test_cli_overrides_env(self, clean_env, monkeypatch): def test_env_fallback(self, clean_env, monkeypatch): """Environment variables used when CLI args are None.""" monkeypatch.setenv("BALATROBOT_PORT", "8888") - monkeypatch.setenv("BALATROBOT_FAST", "1") + monkeypatch.setenv("BALATROBOT_DEBUG", "1") args = Namespace( host=None, port=None, - fast=None, - headless=None, - render_on_api=None, - audio=None, + render=None, debug=None, - no_shaders=None, - balatro_path=None, - lovely_path=None, - love_path=None, + settings=None, + path_balatro=None, + path_lovely=None, + path_love=None, platform=None, - logs_path=None, + path_logs=None, ) config = Config.from_args(args) assert config.port == 8888 - assert config.fast is True + assert config.debug is True + + def test_settings_from_cli(self, clean_env): + """Settings path from CLI args.""" + args = Namespace( + host=None, + port=None, + render=None, + debug=None, + settings="/path/to/profile", + path_balatro=None, + path_lovely=None, + path_love=None, + platform=None, + path_logs=None, + ) + config = Config.from_args(args) + + assert config.settings == "/path/to/profile" class TestConfigFromEnv: @@ -135,13 +168,13 @@ def test_loads_env_vars(self, clean_env, monkeypatch): """Loads configuration from environment variables.""" monkeypatch.setenv("BALATROBOT_PORT", "9999") monkeypatch.setenv("BALATROBOT_HOST", "0.0.0.0") - monkeypatch.setenv("BALATROBOT_FAST", "1") + monkeypatch.setenv("BALATROBOT_DEBUG", "1") config = Config.from_env() assert config.port == 9999 assert config.host == "0.0.0.0" - assert config.fast is True + assert config.debug is True def test_defaults_when_no_env(self, clean_env): """Uses defaults when no env vars set.""" @@ -150,30 +183,88 @@ def test_defaults_when_no_env(self, clean_env): assert config.port == 12346 assert config.host == "127.0.0.1" + def test_render_from_env(self, clean_env, monkeypatch): + """Render mode loaded from environment.""" + monkeypatch.setenv("BALATROBOT_RENDER", "headless") + + config = Config.from_env() + + assert config.render == "headless" + + def test_settings_from_env(self, clean_env, monkeypatch): + """Settings path loaded from environment.""" + monkeypatch.setenv("BALATROBOT_SETTINGS", "/path/to/profile") + + config = Config.from_env() + + assert config.settings == "/path/to/profile" + + def test_path_fields_use_new_names(self, clean_env, monkeypatch): + """New path field names work from environment.""" + monkeypatch.setenv("BALATROBOT_PATH_BALATRO", "/balatro") + monkeypatch.setenv("BALATROBOT_PATH_LOVELY", "/lovely") + monkeypatch.setenv("BALATROBOT_PATH_LOVE", "/love") + monkeypatch.setenv("BALATROBOT_PATH_LOGS", "/logs") + + config = Config.from_env() + + assert config.path_balatro == "/balatro" + assert config.path_lovely == "/lovely" + assert config.path_love == "/love" + assert config.path_logs == "/logs" + class TestConfigToEnv: """Tests for Config.to_env() method.""" def test_serializes_values(self): """Serializes config to environment dict.""" - config = Config(port=9999, fast=True, host="0.0.0.0") + config = Config(port=9999, debug=True, host="0.0.0.0") env = config.to_env() assert env["BALATROBOT_PORT"] == "9999" - assert env["BALATROBOT_FAST"] == "1" + assert env["BALATROBOT_DEBUG"] == "1" assert env["BALATROBOT_HOST"] == "0.0.0.0" def test_skips_none_values(self): """None values are not included.""" - config = Config(balatro_path=None) + config = Config(path_balatro=None) env = config.to_env() - assert "BALATROBOT_BALATRO_PATH" not in env + assert "BALATROBOT_PATH_BALATRO" not in env def test_skips_false_bools(self): """False boolean values are not included.""" - config = Config(fast=False, headless=False) + config = Config(debug=False) + env = config.to_env() + + assert "BALATROBOT_DEBUG" not in env + + def test_includes_render(self): + """Render mode is included in env output.""" + config = Config(render="headless") + env = config.to_env() + + assert env["BALATROBOT_RENDER"] == "headless" + + def test_includes_settings(self): + """Settings path is included in env output.""" + config = Config(settings="/path/to/profile") + env = config.to_env() + + assert env["BALATROBOT_SETTINGS"] == "/path/to/profile" + + def test_uses_new_env_var_names(self): + """Uses BALATROBOT_PATH_* naming convention.""" + config = Config( + path_balatro="/balatro", + path_lovely="/lovely", + path_love="/love", + path_logs="/logs", + ) env = config.to_env() - assert "BALATROBOT_FAST" not in env - assert "BALATROBOT_HEADLESS" not in env + assert env["BALATROBOT_PATH_BALATRO"] == "/balatro" + assert env["BALATROBOT_PATH_LOVELY"] == "/lovely" + assert env["BALATROBOT_PATH_LOVE"] == "/love" + assert env["BALATROBOT_PATH_LOGS"] == "/logs" diff --git a/tests/cli/test_instance.py b/tests/cli/test_instance.py index 35266708..53e26880 100644 --- a/tests/cli/test_instance.py +++ b/tests/cli/test_instance.py @@ -25,8 +25,8 @@ def test_init_with_config(self): def test_init_with_overrides(self): """Overrides apply to base config.""" - config = Config(port=8888, fast=False) - instance = BalatroInstance(config, port=9999, fast=True) + config = Config(port=8888) + instance = BalatroInstance(config, port=9999) assert instance.port == 9999 def test_init_overrides_without_config(self): @@ -164,7 +164,7 @@ async def mock_start(config, session_dir): monkeypatch.setattr("balatrobot.instance.get_launcher", lambda x: mock_launcher) - instance = BalatroInstance(logs_path=str(tmp_path)) + instance = BalatroInstance(path_logs=str(tmp_path)) # Mock health check to succeed immediately instance._wait_for_health = AsyncMock() # ty: ignore[invalid-assignment] diff --git a/tests/cli/test_integration.py b/tests/cli/test_integration.py index a9d5a87d..5f4d721e 100644 --- a/tests/cli/test_integration.py +++ b/tests/cli/test_integration.py @@ -20,7 +20,7 @@ class TestBalatroIntegration: async def test_context_manager_lifecycle(self, tmp_path): """Context manager starts and stops Balatro properly.""" async with BalatroInstance( - port=_random_port(), fast=True, headless=True, logs_path=str(tmp_path) + port=_random_port(), render="headless", path_logs=str(tmp_path) ) as instance: # Instance should be running assert instance.process is not None @@ -43,7 +43,7 @@ async def test_context_manager_lifecycle(self, tmp_path): async def test_health_endpoint_responds(self, tmp_path): """Health endpoint returns valid JSON-RPC response.""" async with BalatroInstance( - port=_random_port(), fast=True, headless=True, logs_path=str(tmp_path) + port=_random_port(), render="headless", path_logs=str(tmp_path) ) as instance: url = f"http://127.0.0.1:{instance.port}" payload = {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 42} diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index cc77ff8a..91891cdc 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -59,7 +59,7 @@ class TestMacOSLauncher: def test_validate_paths_missing_love(self, tmp_path): """Raises RuntimeError when love executable missing.""" launcher = MacOSLauncher() - config = Config(love_path=str(tmp_path / "nonexistent")) + config = Config(path_love=str(tmp_path / "nonexistent")) with pytest.raises(RuntimeError, match="LOVE executable not found"): launcher.validate_paths(config) @@ -67,13 +67,13 @@ def test_validate_paths_missing_love(self, tmp_path): def test_validate_paths_missing_lovely(self, tmp_path): """Raises RuntimeError when liblovely.dylib missing.""" # Create a fake love executable - love_path = tmp_path / "love" - love_path.touch() + path_love = tmp_path / "love" + path_love.touch() launcher = MacOSLauncher() config = Config( - love_path=str(love_path), - lovely_path=str(tmp_path / "nonexistent.dylib"), + path_love=str(path_love), + path_lovely=str(tmp_path / "nonexistent.dylib"), ) with pytest.raises(RuntimeError, match="liblovely.dylib not found"): @@ -82,7 +82,7 @@ def test_validate_paths_missing_lovely(self, tmp_path): def test_build_env_includes_dyld(self, tmp_path): """build_env includes DYLD_INSERT_LIBRARIES.""" launcher = MacOSLauncher() - config = Config(lovely_path="/path/to/liblovely.dylib") + config = Config(path_lovely="/path/to/liblovely.dylib") env = launcher.build_env(config) @@ -91,7 +91,7 @@ def test_build_env_includes_dyld(self, tmp_path): def test_build_cmd(self, tmp_path): """build_cmd returns love executable path.""" launcher = MacOSLauncher() - config = Config(love_path="/path/to/love") + config = Config(path_love="/path/to/love") cmd = launcher.build_cmd(config) @@ -144,8 +144,8 @@ def test_validate_paths_auto_detects_balatro(self, tmp_path, monkeypatch): config = Config() launcher.validate_paths(config) - assert config.balatro_path is not None - assert "Balatro" in config.balatro_path + assert config.path_balatro is not None + assert "Balatro" in config.path_balatro def test_validate_paths_missing_balatro_exe(self, tmp_path, monkeypatch): """Raises RuntimeError when Balatro.exe is missing.""" @@ -194,8 +194,8 @@ def test_build_cmd(self): """build_cmd returns proton run with Balatro.exe.""" launcher = LinuxLauncher() config = Config( - love_path="/path/to/proton", - balatro_path="/path/to/Balatro", + path_love="/path/to/proton", + path_balatro="/path/to/Balatro", ) cmd = launcher.build_cmd(config) assert cmd == ["/path/to/proton", "run", "/path/to/Balatro/Balatro.exe"] @@ -209,8 +209,8 @@ def test_validate_paths_missing_love(self, tmp_path): """Raises RuntimeError when love executable missing.""" launcher = NativeLauncher() config = Config( - love_path=str(tmp_path / "nonexistent"), - balatro_path=str(tmp_path), + path_love=str(tmp_path / "nonexistent"), + path_balatro=str(tmp_path), ) with pytest.raises(RuntimeError, match="LOVE executable not found"): @@ -219,7 +219,7 @@ def test_validate_paths_missing_love(self, tmp_path): def test_build_env_includes_ld_preload(self, tmp_path): """build_env includes LD_PRELOAD.""" launcher = NativeLauncher() - config = Config(lovely_path="/path/to/liblovely.so") + config = Config(path_lovely="/path/to/liblovely.so") env = launcher.build_env(config) @@ -228,7 +228,7 @@ def test_build_env_includes_ld_preload(self, tmp_path): def test_build_cmd(self, tmp_path): """build_cmd returns love and balatro path.""" launcher = NativeLauncher() - config = Config(love_path="/usr/bin/love", balatro_path="/path/to/balatro") + config = Config(path_love="/usr/bin/love", path_balatro="/path/to/balatro") cmd = launcher.build_cmd(config) @@ -242,7 +242,7 @@ class TestWindowsLauncher: def test_validate_paths_missing_balatro_exe(self, tmp_path): """Raises RuntimeError when Balatro.exe missing.""" launcher = WindowsLauncher() - config = Config(love_path=str(tmp_path / "nonexistent.exe")) + config = Config(path_love=str(tmp_path / "nonexistent.exe")) with pytest.raises(RuntimeError, match="Balatro executable not found"): launcher.validate_paths(config) @@ -255,8 +255,8 @@ def test_validate_paths_missing_version_dll(self, tmp_path): launcher = WindowsLauncher() config = Config( - love_path=str(exe_path), - lovely_path=str(tmp_path / "nonexistent.dll"), + path_love=str(exe_path), + path_lovely=str(tmp_path / "nonexistent.dll"), ) with pytest.raises(RuntimeError, match="version.dll not found"): @@ -265,7 +265,7 @@ def test_validate_paths_missing_version_dll(self, tmp_path): def test_build_env_no_dll_injection_var(self, tmp_path): """build_env does not include DLL injection environment variable.""" launcher = WindowsLauncher() - config = Config(lovely_path=r"C:\path\to\version.dll") + config = Config(path_lovely=r"C:\path\to\version.dll") env = launcher.build_env(config) @@ -275,7 +275,7 @@ def test_build_env_no_dll_injection_var(self, tmp_path): def test_build_cmd(self, tmp_path): """build_cmd returns Balatro.exe path.""" launcher = WindowsLauncher() - config = Config(love_path=r"C:\path\to\Balatro.exe") + config = Config(path_love=r"C:\path\to\Balatro.exe") cmd = launcher.build_cmd(config) diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py index feec40d8..a186781b 100644 --- a/tests/cli/test_pool.py +++ b/tests/cli/test_pool.py @@ -97,7 +97,7 @@ class TestBalatroPoolStartStop: @pytest.mark.asyncio async def test_start_creates_instances(self, tmp_path, monkeypatch): """start() creates n instances and populates instances list.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) # Mock BalatroInstance mock_inst = AsyncMock(spec=BalatroInstance) @@ -133,7 +133,7 @@ def mock_instance_factory(config_arg, **kwargs): @pytest.mark.asyncio async def test_stop_concurrent(self, tmp_path): """stop() stops all instances concurrently.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_instances = [] for port in [14001, 14002]: @@ -156,7 +156,7 @@ async def test_stop_concurrent(self, tmp_path): @pytest.mark.asyncio async def test_start_fail_cleans_up(self, tmp_path): """If one instance fails to start, all are stopped.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) started_inst = MagicMock(spec=BalatroInstance) started_inst.port = 14001 @@ -185,14 +185,14 @@ async def test_start_fail_cleans_up(self, tmp_path): @pytest.mark.asyncio async def test_stop_idempotent(self, tmp_path): """stop() is safe to call when not started.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) pool = BalatroPool(config) await pool.stop() # Should not raise @pytest.mark.asyncio async def test_start_already_started(self, tmp_path): """start() raises if already started.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) pool = BalatroPool(config) mock_inst = MagicMock(spec=BalatroInstance) @@ -210,7 +210,7 @@ async def test_start_already_started(self, tmp_path): @pytest.mark.asyncio async def test_instances_populated_after_start(self, tmp_path): """instances returns InstanceInfo list after start.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_instances = [] for port in [14001, 14002]: @@ -287,7 +287,7 @@ class TestBalatroPoolPortAllocation: @pytest.mark.asyncio async def test_auto_allocate_ports(self, tmp_path): """Pool allocates ports automatically when none specified.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) pool = BalatroPool(config, n=2) captured_ports = [] @@ -320,7 +320,7 @@ class TestBalatroPoolConfigDerivation: @pytest.mark.asyncio async def test_derives_config_per_instance(self, tmp_path): """Pool derives configs from base config, each with unique port.""" - config = Config(host="0.0.0.0", logs_path=str(tmp_path)) + config = Config(host="0.0.0.0", path_logs=str(tmp_path)) captured_configs = [] captured_overrides = [] @@ -341,7 +341,7 @@ def mock_instance_factory(config_arg, **kwargs): pool = BalatroPool(config, ports=[14001, 14002]) await pool.start() - # All configs should share host/logs_path + # All configs should share host/path_logs assert all(c.host == "0.0.0.0" for c in captured_configs) # Each gets a different port assert captured_overrides[0]["port"] == 14001 @@ -352,7 +352,7 @@ def mock_instance_factory(config_arg, **kwargs): @pytest.mark.asyncio async def test_shared_session_name(self, tmp_path): """Pool generates a shared session name for all instances.""" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) captured_session_names = [] diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index f2661fb0..d3c7f33a 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -4,6 +4,7 @@ from balatrobot.cli import app from balatrobot.cli.serve import PLATFORM_CHOICES +from balatrobot.config import Config runner = CliRunner() @@ -27,16 +28,33 @@ def test_serve_valid_platforms(self): # --- Num instances validation tests --- def test_serve_num_instances_zero(self): - """--num-instances 0 rejected with error message.""" - result = runner.invoke(app, ["serve", "-n", "0"]) + """--num 0 rejected with error message.""" + result = runner.invoke(app, ["serve", "--num", "0"]) assert result.exit_code == 1 - assert "--num-instances must be >= 1" in result.output + assert "--num must be >= 1" in result.output def test_serve_num_instances_negative(self): - """Negative --num-instances rejected.""" - result = runner.invoke(app, ["serve", "-n", "-1"]) + """Negative --num rejected.""" + result = runner.invoke(app, ["serve", "--num", "-1"]) assert result.exit_code == 1 - assert "--num-instances must be >= 1" in result.output + assert "--num must be >= 1" in result.output + + # --- Render mode validation tests --- + + def test_serve_invalid_render_mode(self): + """Invalid render mode rejected with error message.""" + result = runner.invoke(app, ["serve", "--render", "invalid"]) + assert result.exit_code == 1 + assert "Invalid render mode 'invalid'" in result.output + + def test_serve_render_validation_uses_config(self, clean_env): + """Render modes are validated against RENDER_CHOICES constant.""" + from balatrobot.config import RENDER_CHOICES + + # All render modes should be valid config values + for mode in RENDER_CHOICES: + config = Config(render=mode) + assert config.render == mode # --- Help text tests --- @@ -44,13 +62,26 @@ def test_serve_help(self): """serve --help shows all options.""" result = runner.invoke(app, ["serve", "--help"]) assert result.exit_code == 0 - assert "--fast" in result.output - assert "--headless" in result.output + assert "--settings" in result.output + assert "--render" in result.output assert "--platform" in result.output - assert "--num-instances" in result.output or "-n" in result.output - # --host and --port removed from serve (ephemeral ports, state file discovery) - assert "--host" not in result.output - assert "--port" not in result.output + assert "--num" in result.output + assert "--debug" in result.output + assert "--path-balatro" in result.output + assert "--path-lovely" in result.output + assert "--path-love" in result.output + assert "--path-logs" in result.output + # Old flags should NOT be present + assert "--fast" not in result.output + assert "--headless" not in result.output + assert "--render-on-api" not in result.output + assert "--audio" not in result.output + assert "--no-shaders" not in result.output + assert "--gamespeed" not in result.output + assert "--fps-cap" not in result.output + assert "--animation-fps" not in result.output + assert "--no-reduced-motion" not in result.output + assert "--pixel-art-smoothing" not in result.output # --- Config.from_kwargs tests --- @@ -77,11 +108,11 @@ def test_config_from_kwargs_env_var_fallback(self, clean_env, monkeypatch): """Env vars used when options not provided.""" from balatrobot.config import Config - monkeypatch.setenv("BALATROBOT_FAST", "1") + monkeypatch.setenv("BALATROBOT_DEBUG", "1") monkeypatch.setenv("BALATROBOT_PORT", "8888") - config = Config.from_kwargs(fast=None, port=None) - assert config.fast is True + config = Config.from_kwargs(debug=None, port=None) + assert config.debug is True assert config.port == 8888 diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py index c9b575ff..fd8a497f 100644 --- a/tests/cli/test_server.py +++ b/tests/cli/test_server.py @@ -20,7 +20,7 @@ class TestServerContextManager: async def test_enter_writes_state_exit_deletes(self, tmp_path): """State file exists inside with block, gone after exit.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 @@ -61,7 +61,7 @@ async def test_enter_double_start_raises_busy(self, tmp_path): } state_path.write_text(json.dumps(state_data)) - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) server = Server(config, n=1, state_path=state_path) with pytest.raises(StateFileBusy): @@ -71,7 +71,7 @@ async def test_enter_double_start_raises_busy(self, tmp_path): async def test_enter_pool_failure_cleans_up(self, tmp_path): """No state file left if pool.start() fails.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 @@ -94,7 +94,7 @@ async def test_enter_pool_failure_cleans_up(self, tmp_path): async def test_pool_property(self, tmp_path): """Server.pool returns the pool after enter.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 @@ -119,7 +119,7 @@ class TestServerRun: async def test_run_sigterm_exits_cleanly(self, tmp_path): """run() returns normally when shutdown event is set.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 @@ -141,7 +141,7 @@ async def test_run_sigterm_exits_cleanly(self, tmp_path): async def test_run_child_death_raises(self, tmp_path): """run() raises InstanceDiedError when child dies, state file cleaned up.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 @@ -172,7 +172,7 @@ async def test_run_child_death_raises(self, tmp_path): async def test_run_skips_signal_handler_on_windows(self, tmp_path): """run() does not register signal handlers on Windows.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 @@ -203,7 +203,7 @@ async def test_run_skips_signal_handler_on_windows(self, tmp_path): async def test_run_registers_sigterm_handler(self, tmp_path): """run() registers a SIGTERM handler on non-Windows.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 @@ -235,7 +235,7 @@ async def test_run_registers_sigterm_handler(self, tmp_path): async def test_sigterm_triggers_clean_shutdown(self, tmp_path): """SIGTERM sets shutdown event → run() exits → __aexit__ cleans up.""" state_path = tmp_path / "state.json" - config = Config(logs_path=str(tmp_path)) + config = Config(path_logs=str(tmp_path)) mock_inst = MagicMock() mock_inst.port = 14001 From 3ef66fa694ac0cd9e15385e271bc6f9f5225ac8b Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 15:27:46 +0200 Subject: [PATCH 069/121] docs: update CLI reference for profile-based settings and render modes Rewrite docs/cli.md to document the new --settings, --render, --path-* flags. Add profile activation section explaining the BalatroBot profile gate. Remove documentation for deleted flags. Add render modes table and settings profiles section with directory structure. Update SKILL.md to match new invocation patterns. --- .agents/skills/balatrobot/SKILL.md | 13 ++- docs/cli.md | 129 ++++++++++++++++------------- 2 files changed, 82 insertions(+), 60 deletions(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index 8ec62988..6cbb1e50 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -16,12 +16,19 @@ balatrobot serve --help Typical invocation: ```bash -balatrobot serve --headless --fast --debug +balatrobot serve --render headless --settings ~/balatrosettings/profiles/fast --debug ``` -Key flags: `--headless`, `--fast` (10× speed), `--debug` (DebugPlus logging), `-n`/`--num-instances` (pool). +Key flags: +- `--render [headfull|headless|ondemand]` — rendering mode (default: headfull) +- `--settings PATH` — path to balatrosettings profile directory +- `--debug` — enable debug endpoints +- `--num` — number of instances +- `--path-*` — path overrides (`--path-balatro`, `--path-lovely`, `--path-love`, `--path-logs`) -All flags have `BALATROBOT_*` env var equivalents (e.g. `BALATROBOT_FAST=1`). See `src/balatrobot/config.py` for the full mapping. +All flags have `BALATROBOT_*` env var equivalents (e.g. `BALATROBOT_RENDER=headless`). See `src/balatrobot/config.py` for the full mapping. + +**Requirement:** The mod only activates when the selected Balatro in-game profile is named exactly `BalatroBot`. Create this profile in Balatro's profile selector and select it before launching via `serve`. `serve` auto-allocates ports, prints instance URLs and the session logs directory, then blocks until Ctrl+C. It writes a state file so other commands can discover the running instances. diff --git a/docs/cli.md b/docs/cli.md index 133f6d95..b661ddfc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,37 +25,51 @@ Start Balatro with the BalatroBot mod loaded and API server running. uvx balatrobot serve [OPTIONS] ``` +### Profile Activation + +The BalatroBot mod only activates when the selected Balatro in-game profile is named exactly `"BalatroBot"` (case-sensitive). If no such profile exists, the HTTP server does not start and no settings overrides are applied. The game boots normally. + ### Options All options can be set via CLI flags or environment variables. CLI flags override environment variables. -| CLI Flag | Environment Variable | Default | Description | -| ------------------------------- | -------------------------------- | ------------- | ------------------------------------------ | -| `--host HOST` | `BALATROBOT_HOST` | `127.0.0.1` | Server hostname | -| `--port PORT` | `BALATROBOT_PORT` | `12346` | Server port | -| `--fast` | `BALATROBOT_FAST` | `0` | Enable fast mode (10x game speed) | -| `--headless` | `BALATROBOT_HEADLESS` | `0` | Enable headless mode (minimal rendering) | -| `--render-on-api` | `BALATROBOT_RENDER_ON_API` | `0` | Render only on API calls | -| `--audio` | `BALATROBOT_AUDIO` | `0` | Enable audio | -| `--debug` | `BALATROBOT_DEBUG` | `0` | Enable debug mode (requires DebugPlus mod) | -| `--no-shaders` | `BALATROBOT_NO_SHADERS` | `0` | Disable all shaders | -| `--fps-cap FPS_CAP` | `BALATROBOT_FPS_CAP` | `60` | Maximum FPS cap | -| `--gamespeed GAMESPEED` | `BALATROBOT_GAMESPEED` | `4` | Game speed multiplier | -| `--animation-fps ANIMATION_FPS` | `BALATROBOT_ANIMATION_FPS` | `10` | Animation FPS | -| `--no-reduced-motion` | `BALATROBOT_NO_REDUCED_MOTION` | `0` | Disable reduced motion | -| `--pixel-art-smoothing` | `BALATROBOT_PIXEL_ART_SMOOTHING` | `0` | Enable pixel art smoothing | -| `--balatro-path BALATRO_PATH` | `BALATROBOT_BALATRO_PATH` | auto-detected | Path to Balatro game directory | -| `--lovely-path LOVELY_PATH` | `BALATROBOT_LOVELY_PATH` | auto-detected | Path to lovely library (dll/so/dylib) | -| `--love-path LOVE_PATH` | `BALATROBOT_LOVE_PATH` | auto-detected | Path to game launcher executable | -| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: darwin, linux, windows, native | -| `--logs-path LOGS_PATH` | `BALATROBOT_LOGS_PATH` | `logs` | Directory for log files | -| `-h, --help` | - | - | Show help message and exit | - -!!! note "Mutually Exclusive Flags" - - `--headless` and `--render-on-api` are mutually exclusive. - -**Note:** Boolean flags (`--fast`, `--headless`, etc.) use `1` for enabled and `0` for disabled when set via environment variables. +| CLI Flag | Environment Variable | Default | Description | +| --------------------- | ------------------------- | ------------- | -------------------------------------------------- | +| `--settings PATH` | `BALATROBOT_SETTINGS` | *(none)* | Path to balatrosettings profile directory | +| `--render MODE` | `BALATROBOT_RENDER` | `headfull` | Render mode: `headfull`, `headless`, or `ondemand` | +| `--debug` | `BALATROBOT_DEBUG` | `0` | Enable debug mode (requires DebugPlus mod) | +| `--host HOST` | `BALATROBOT_HOST` | `127.0.0.1` | Server hostname | +| `--port PORT` | `BALATROBOT_PORT` | `12346` | Server port | +| `--num N` | `BALATROBOT_NUM` | `1` | Number of instances to start | +| `--path-balatro PATH` | `BALATROBOT_PATH_BALATRO` | auto-detected | Path to Balatro game directory | +| `--path-lovely PATH` | `BALATROBOT_PATH_LOVELY` | auto-detected | Path to lovely library (dll/so/dylib) | +| `--path-love PATH` | `BALATROBOT_PATH_LOVE` | auto-detected | Path to game launcher executable | +| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: `darwin`, `linux`, `windows`, `native` | +| `--path-logs PATH` | `BALATROBOT_PATH_LOGS` | `logs` | Directory for log files | +| `-h, --help` | - | - | Show help message and exit | + +### Render Modes + +| Mode | Behavior | +| ---------- | --------------------------------------------------------------------------------------------- | +| `headfull` | Normal rendering. Game window visible and fully interactive. | +| `headless` | All rendering disabled. Window hidden at 1×1 pixels. Use for CI/automated environments. | +| `ondemand` | Frames rendered only when explicitly requested via the API. Use with the screenshot endpoint. | + +### Settings Profiles + +The `--settings` flag points to a [balatrosettings](https://github.com/S1M0N38/balatrosettings) profile directory containing `settings.lua` and `1/profile.lua`. These are deep-merged into Balatro's `G.SETTINGS` and `G.PROFILES` tables, allowing partial overrides. + +Example profile structure: + +``` +my-profile/ + settings.lua # Returns table merged into G.SETTINGS + 1/ + profile.lua # Returns table merged into G.PROFILES[1] +``` + +**Note:** When using `--settings`, both files must exist and return valid Lua tables. The profile load fails fast with a clear error if files are missing or malformed. ## api Command @@ -114,17 +128,14 @@ On error, prints `Error: NAME - message` to stderr (exit code 1). ### Basic Usage ```bash -# Start with default settings +# Start with default settings (headfull, no profile) uvx balatrobot serve -# Start with fast mode for development -uvx balatrobot serve --fast +# Start headless with a fast profile +uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless # Start with debug mode (requires DebugPlus mod) -uvx balatrobot serve --fast --debug - -# Start headless for automated testing -uvx balatrobot serve --headless --fast +uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --debug ``` ### Custom Configuration @@ -134,7 +145,10 @@ uvx balatrobot serve --headless --fast uvx balatrobot serve --port 8080 # Custom Balatro installation -uvx balatrobot serve --balatro-path /path/to/Balatro.exe +uvx balatrobot serve --path-balatro /path/to/Balatro + +# On-demand rendering for screenshot capture +uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render ondemand ``` ## Examples with Environment Variables @@ -144,7 +158,8 @@ uvx balatrobot serve --balatro-path /path/to/Balatro.exe ```bash # Configure via environment variables export BALATROBOT_PORT=8080 -export BALATROBOT_FAST=1 +export BALATROBOT_RENDER=headless +export BALATROBOT_SETTINGS=~/balatrosettings/profiles/headless # Launch with defaults from env vars uvx balatrobot serve @@ -157,7 +172,7 @@ uvx balatrobot serve --port 9000 # Uses port 9000, not 8080 ```powershell $env:BALATROBOT_PORT = "8080" -$env:BALATROBOT_FAST = "1" +$env:BALATROBOT_RENDER = "headless" uvx balatrobot serve ``` @@ -177,8 +192,8 @@ The `windows` platform launches Balatro via Steam on Windows. The CLI auto-detec **Auto-Detected Paths:** -- `BALATROBOT_LOVE_PATH`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe` -- `BALATROBOT_LOVELY_PATH`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll` +- `BALATROBOT_PATH_LOVE`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe` +- `BALATROBOT_PATH_LOVELY`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll` **Requirements:** @@ -190,10 +205,10 @@ The `windows` platform launches Balatro via Steam on Windows. The CLI auto-detec ```powershell # Auto-detects paths -uvx balatrobot serve --fast +uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless # Or specify custom paths -uvx balatrobot serve --love-path "C:\Custom\Path\Balatro.exe" --lovely-path "C:\Custom\Path\version.dll" +uvx balatrobot serve --path-love "C:\Custom\Path\Balatro.exe" --path-lovely "C:\Custom\Path\version.dll" ``` ### macOS Platform @@ -202,8 +217,8 @@ The `darwin` platform launches Balatro via Steam on macOS. The CLI auto-detects **Auto-Detected Paths:** -- `BALATROBOT_LOVE_PATH`: `~/Library/Application Support/Steam/steamapps/common/Balatro/Balatro.app/Contents/MacOS/love` -- `BALATROBOT_LOVELY_PATH`: `~/Library/Application Support/Steam/steamapps/common/Balatro/liblovely.dylib` +- `BALATROBOT_PATH_LOVE`: `~/Library/Application Support/Steam/steamapps/common/Balatro/Balatro.app/Contents/MacOS/love` +- `BALATROBOT_PATH_LOVELY`: `~/Library/Application Support/Steam/steamapps/common/Balatro/liblovely.dylib` **Requirements:** @@ -217,10 +232,10 @@ The `darwin` platform launches Balatro via Steam on macOS. The CLI auto-detects ```bash # Auto-detects paths -uvx balatrobot serve --fast +uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless # Or specify custom paths -uvx balatrobot serve --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib" +uvx balatrobot serve --path-love "/path/to/love" --path-lovely "/path/to/liblovely.dylib" ``` ### Linux (Proton) Platform @@ -229,9 +244,9 @@ The `linux` platform launches Balatro via Steam Proton. The CLI auto-detects Ste **Auto-Detected Paths:** -- `BALATROBOT_BALATRO_PATH`: `~/.local/share/Steam/steamapps/common/Balatro` -- `BALATROBOT_LOVE_PATH`: Best available Proton executable (scans `steamapps/common/`) -- `BALATROBOT_LOVELY_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll` +- `BALATROBOT_PATH_BALATRO`: `~/.local/share/Steam/steamapps/common/Balatro` +- `BALATROBOT_PATH_LOVE`: Best available Proton executable (scans `steamapps/common/`) +- `BALATROBOT_PATH_LOVELY`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll` **Requirements:** @@ -244,10 +259,10 @@ The `linux` platform launches Balatro via Steam Proton. The CLI auto-detects Ste ```bash # Auto-detects paths -uvx balatrobot serve --fast +uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless # Or specify custom paths -uvx balatrobot serve --love-path /path/to/proton --balatro-path /path/to/Balatro +uvx balatrobot serve --path-love /path/to/proton --path-balatro /path/to/Balatro ``` !!! warning "Steam Installation" @@ -260,9 +275,9 @@ The `native` platform runs Balatro from source code using the LÖVE framework in **Required Paths:** -- `BALATROBOT_BALATRO_PATH`: Directory containing Balatro source code with `main.lua` -- `BALATROBOT_LOVE_PATH`: Path to LÖVE executable (find with `which love`), e.g., `/usr/bin/love` -- `BALATROBOT_LOVELY_PATH`: Must be `/usr/local/lib/liblovely.so` +- `BALATROBOT_PATH_BALATRO`: Directory containing Balatro source code with `main.lua` +- `BALATROBOT_PATH_LOVE`: Path to LÖVE executable (find with `which love`), e.g., `/usr/bin/love` +- `BALATROBOT_PATH_LOVELY`: Must be `/usr/local/lib/liblovely.so` - Mods directory: `~/.config/love/Mods` (auto-discovered, used by lovely) - Settings directory: `~/.local/share/love/balatro` (must contain game settings) @@ -274,7 +289,7 @@ mkdir -p ~/.local/share/love/balatro cp -r /path/to/balatro/settings/* ~/.local/share/love/balatro/ # Launch with native platform -uvx balatrobot serve --platform native --balatro-path /path/to/balatro/source +uvx balatrobot serve --platform native --path-balatro /path/to/balatro/source ``` ??? tip "Hyprland Configuration" @@ -302,10 +317,10 @@ uvx balatrobot serve --platform native --balatro-path /path/to/balatro/source ## Troubleshooting -**Connection refused**: Ensure Balatro is running and the mod loaded successfully. Check logs in `logs/{timestamp}/{port}.log` for errors. +**Connection refused**: Ensure Balatro is running and the mod loaded successfully. Check logs in `logs/{timestamp}/{port}.log` for errors. Verify the in-game profile is named exactly `"BalatroBot"`. -**Mod not loading**: Verify that Lovely Injector and Steamodded are installed correctly. +**Mod not loading**: Verify that Lovely Injector and Steamodded are installed correctly. Ensure you have a Balatro profile named `"BalatroBot"` and it is selected. **Port in use**: Change the port with `--port` or set `BALATROBOT_PORT` to a different value. -**Game crashes**: Try disabling shaders with `--no-shaders` or running in headless mode with `--headless`. +**Game crashes**: Try running in headless mode with `--render headless` and a minimal settings profile. From a633550f2c78fa28ba2566afce7294c3ad98e33d Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 16:03:35 +0200 Subject: [PATCH 070/121] fix(config): validate render mode in __post_init__ instead of CLI layer Config silently accepted invalid render values. Validation now lives on the dataclass so all constructors (from_env, from_kwargs, direct) are protected. serve.py catches ValueError for clean CLI output. --- src/balatrobot/cli/serve.py | 38 +++++++++++++++++-------------------- src/balatrobot/config.py | 7 +++++++ 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index f6a4b7b3..58782029 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -9,7 +9,7 @@ import typer -from balatrobot.config import RENDER_CHOICES, Config +from balatrobot.config import Config from balatrobot.instance import InstanceDiedError from balatrobot.pool import BalatroPool from balatrobot.state import StateFile, StateFileBusy, default_state_path @@ -136,31 +136,27 @@ def serve( ) raise typer.Exit(code=1) - if render is not None and render not in RENDER_CHOICES: - typer.echo( - f"Error: Invalid render mode '{render}'. " - f"Choose from: {', '.join(sorted(RENDER_CHOICES))}", - err=True, - ) - raise typer.Exit(code=1) - if num < 1: typer.echo(f"Error: --num must be >= 1, got {num}.", err=True) raise typer.Exit(code=1) # Build config from kwargs with env var fallback - config = Config.from_kwargs( - settings=settings, - render=render, - debug=debug, - host=host, - port=port, - path_balatro=path_balatro, - path_lovely=path_lovely, - path_love=path_love, - platform=platform, - path_logs=path_logs, - ) + try: + config = Config.from_kwargs( + settings=settings, + render=render, + debug=debug, + host=host, + port=port, + path_balatro=path_balatro, + path_lovely=path_lovely, + path_love=path_love, + platform=platform, + path_logs=path_logs, + ) + except ValueError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) try: asyncio.run(_serve(config, num)) diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index bd1993e5..103528cf 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -57,6 +57,13 @@ class Config: platform: str | None = None path_logs: str = "logs" + def __post_init__(self) -> None: + if self.render not in RENDER_CHOICES: + raise ValueError( + f"Invalid render mode '{self.render}'. " + f"Choose from: {', '.join(sorted(RENDER_CHOICES))}" + ) + @classmethod def from_args(cls, args) -> Self: """Create Config from CLI args with env var fallback.""" From 8e2b0b864ff8e026e1a90763dc6bea07478ef720 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 16:03:43 +0200 Subject: [PATCH 071/121] refactor(settings): trim Lua env var header, rename settings_path to settings Header now only lists env vars the Lua mod actually reads (5 not 10). Renamed BB_SETTINGS.settings_path to settings for consistency with Python config. Added clarifying comment on balatrosettings "1/" directory convention. --- src/lua/settings.lua | 17 ++++++----------- src/lua/utils/types.lua | 2 +- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 6cf7809c..2f22bdcc 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -1,18 +1,12 @@ --[[ BalatroBot v2 settings — profile-based configuration. -Environment variables: +Environment variables read by the Lua mod: BALATROBOT_HOST - Server hostname (default: 127.0.0.1) BALATROBOT_PORT - Server port (default: 12346) BALATROBOT_RENDER - Render mode: headfull|headless|ondemand (default: headfull) BALATROBOT_DEBUG - Enable debug endpoints (1/0, default: 0) BALATROBOT_SETTINGS - Path to balatrosettings profile directory - BALATROBOT_PATH_BALATRO - Path to Balatro directory - BALATROBOT_PATH_LOVELY - Path to lovely library - BALATROBOT_PATH_LOVE - Path to LOVE executable - BALATROBOT_PLATFORM - Platform override - BALATROBOT_PATH_LOGS - Log directory - BALATROBOT_NUM - Number of instances ]] ---@diagnostic disable: duplicate-set-field @@ -23,7 +17,7 @@ BB_SETTINGS = { port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, render = os.getenv("BALATROBOT_RENDER") or "headfull", debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, - settings_path = os.getenv("BALATROBOT_SETTINGS"), + settings = os.getenv("BALATROBOT_SETTINGS"), } ---@type boolean? @@ -54,7 +48,8 @@ local function apply_profile(path) assert(type(profile_settings) == "table", "settings.lua must return a table") deep_merge(G.SETTINGS, profile_settings) - -- Deep merge 1/profile.lua into G.PROFILES[n] + -- Deep merge balatrosettings profile data into G.PROFILES[n] + -- "1/" is a balatrosettings directory convention, not the in-game profile slot local profile_src = NFS.read(path .. "/1/profile.lua") assert(profile_src, "Profile not found: " .. path .. "/1/profile.lua") local profile_data = assert(load(profile_src))() @@ -152,8 +147,8 @@ BB_SETTINGS.setup = function() G.PROFILES[profile_num].all_unlocked = true -- Apply settings profile if --settings provided - if BB_SETTINGS.settings_path then - apply_profile(BB_SETTINGS.settings_path) + if BB_SETTINGS.settings then + apply_profile(BB_SETTINGS.settings) end -- Render mode diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 1ebfabfe..67d6829f 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -277,7 +277,7 @@ ---@field port integer Port number for the HTTP server (default: 12346) ---@field render string Render mode: headfull|headless|ondemand (default: "headfull") ---@field debug boolean Whether debug mode is enabled (requires DebugPlus mod) ----@field settings_path string? Path to balatrosettings profile directory (nil if not provided) +---@field settings string? Path to balatrosettings profile directory (nil if not provided) ---@field setup fun(): boolean Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. ---@class Debug From c3b8e00cfc2bcffe0cc7c1011ffcd9c826688fd2 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 16:03:57 +0200 Subject: [PATCH 072/121] test(config): replace TestRenderChoices with meaningful validation tests Deleted TestRenderChoices (4 tests asserting a constant equals itself). Added TestConfigRenderValidation with valid-mode acceptance and invalid-mode rejection tests. Removed misleading serve-level test that did not actually test the serve command. --- tests/cli/test_config.py | 31 +++++++++++++------------------ tests/cli/test_serve_cmd.py | 10 ---------- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 310dbdb3..c9c2cc75 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -57,24 +57,19 @@ def test_defaults(self, clean_env): assert config.platform is None -class TestRenderChoices: - """Tests for RENDER_CHOICES constant.""" - - def test_render_choices(self): - """RENDER_CHOICES contains the three valid modes.""" - assert RENDER_CHOICES == frozenset({"headfull", "headless", "ondemand"}) - - def test_headfull_in_choices(self): - """headfull is a valid render mode.""" - assert "headfull" in RENDER_CHOICES - - def test_headless_in_choices(self): - """headless is a valid render mode.""" - assert "headless" in RENDER_CHOICES - - def test_ondemand_in_choices(self): - """ondemand is a valid render mode.""" - assert "ondemand" in RENDER_CHOICES +class TestConfigRenderValidation: + """Tests for render mode validation in Config.""" + + def test_valid_render_modes_accepted(self): + """All RENDER_CHOICES produce valid configs.""" + for mode in RENDER_CHOICES: + config = Config(render=mode) + assert config.render == mode + + def test_invalid_render_mode_rejected(self): + """Invalid render mode raises ValueError.""" + with pytest.raises(ValueError, match="Invalid render mode"): + Config(render="invalid") class TestConfigFromArgs: diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index d3c7f33a..167a2abb 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -4,7 +4,6 @@ from balatrobot.cli import app from balatrobot.cli.serve import PLATFORM_CHOICES -from balatrobot.config import Config runner = CliRunner() @@ -47,15 +46,6 @@ def test_serve_invalid_render_mode(self): assert result.exit_code == 1 assert "Invalid render mode 'invalid'" in result.output - def test_serve_render_validation_uses_config(self, clean_env): - """Render modes are validated against RENDER_CHOICES constant.""" - from balatrobot.config import RENDER_CHOICES - - # All render modes should be valid config values - for mode in RENDER_CHOICES: - config = Config(render=mode) - assert config.render == mode - # --- Help text tests --- def test_serve_help(self): From 6636231a080d6c0fe144660f8a3546687eca9817 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 16:04:05 +0200 Subject: [PATCH 073/121] docs(cli): mark --num as CLI-only, no env var equivalent The options table claimed --num maps to BALATROBOT_NUM but that env var was never consumed. num is a serve() local parameter, not a Config field, and has no env var fallback. --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index b661ddfc..c6898c9f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,7 +40,7 @@ All options can be set via CLI flags or environment variables. CLI flags overrid | `--debug` | `BALATROBOT_DEBUG` | `0` | Enable debug mode (requires DebugPlus mod) | | `--host HOST` | `BALATROBOT_HOST` | `127.0.0.1` | Server hostname | | `--port PORT` | `BALATROBOT_PORT` | `12346` | Server port | -| `--num N` | `BALATROBOT_NUM` | `1` | Number of instances to start | +| `--num N` | - | `1` | Number of instances to start (CLI only) | | `--path-balatro PATH` | `BALATROBOT_PATH_BALATRO` | auto-detected | Path to Balatro game directory | | `--path-lovely PATH` | `BALATROBOT_PATH_LOVELY` | auto-detected | Path to lovely library (dll/so/dylib) | | `--path-love PATH` | `BALATROBOT_PATH_LOVE` | auto-detected | Path to game launcher executable | From 55c898a84e882c8e5eebfb6b7392000991e11ded Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 17:13:35 +0200 Subject: [PATCH 074/121] feat(mod): add bundled settings profiles (default, fast, headless) Profiles live under src/lua/profiles/<name>/ with settings.lua (required, merged into G.SETTINGS) and profile.lua (optional, merged into G.PROFILES). Three profiles provided: - default: balanced settings, borderless window, audio on - fast: max speed (128x), minimal graphics, no audio - headless: normal speed, no audio, minimal graphics --- src/lua/profiles/default/profile.lua | 1 + src/lua/profiles/default/settings.lua | 20 ++++++++++++++++++++ src/lua/profiles/fast/profile.lua | 1 + src/lua/profiles/fast/settings.lua | 23 +++++++++++++++++++++++ src/lua/profiles/headless/profile.lua | 1 + src/lua/profiles/headless/settings.lua | 19 +++++++++++++++++++ 6 files changed, 65 insertions(+) create mode 100644 src/lua/profiles/default/profile.lua create mode 100644 src/lua/profiles/default/settings.lua create mode 100644 src/lua/profiles/fast/profile.lua create mode 100644 src/lua/profiles/fast/settings.lua create mode 100644 src/lua/profiles/headless/profile.lua create mode 100644 src/lua/profiles/headless/settings.lua diff --git a/src/lua/profiles/default/profile.lua b/src/lua/profiles/default/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/default/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/default/settings.lua b/src/lua/profiles/default/settings.lua new file mode 100644 index 00000000..153c8435 --- /dev/null +++ b/src/lua/profiles/default/settings.lua @@ -0,0 +1,20 @@ +return { + ["GAMESPEED"] = 4, + ["SOUND"] = { + ["game_sounds_volume"] = 100, + ["music_volume"] = 100, + ["volume"] = 50, + }, + ["WINDOW"] = { + ["screenmode"] = "Borderless", + ["screen_res"] = { + ["h"] = 480, + ["w"] = 820, + }, + }, + ["crashreports"] = false, + ["language"] = "en-us", + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", +} diff --git a/src/lua/profiles/fast/profile.lua b/src/lua/profiles/fast/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/fast/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/fast/settings.lua b/src/lua/profiles/fast/settings.lua new file mode 100644 index 00000000..2f2c58f1 --- /dev/null +++ b/src/lua/profiles/fast/settings.lua @@ -0,0 +1,23 @@ +return { + ["GAMESPEED"] = 128, + ["GRAPHICS"] = { + ["shadows"] = "Off", + ["texture_scaling"] = 1, + ["crt"] = 0, + ["bloom"] = 0, + }, + ["SOUND"] = { + ["volume"] = 0, + ["music_volume"] = 0, + ["game_sounds_volume"] = 0, + }, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["screenshake"] = 0, + ["reduced_motion"] = true, + ["WINDOW"] = { + ["screenmode"] = "Windowed", + ["vsync"] = 0, + }, +} diff --git a/src/lua/profiles/headless/profile.lua b/src/lua/profiles/headless/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/headless/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/headless/settings.lua b/src/lua/profiles/headless/settings.lua new file mode 100644 index 00000000..83a85a23 --- /dev/null +++ b/src/lua/profiles/headless/settings.lua @@ -0,0 +1,19 @@ +return { + ["GAMESPEED"] = 4, + ["GRAPHICS"] = { + ["shadows"] = "Off", + }, + ["SOUND"] = { + ["volume"] = 0, + ["music_volume"] = 0, + ["game_sounds_volume"] = 0, + }, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["screenshake"] = 0, + ["reduced_motion"] = true, + ["WINDOW"] = { + ["vsync"] = 0, + }, +} From 8173c9fa8f8c9a0874b1494079410fe36fb2e7f9 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 17:13:50 +0200 Subject: [PATCH 075/121] refactor(mod): resolve settings profiles by name instead of absolute path apply_profile() now accepts a bare profile name and resolves it via SMODS.current_mod.path instead of receiving an absolute path. The profile.lua file is now optional (merge skipped if absent). If a profile is not found, the error message lists all available profiles. BB_SETTINGS.setup() defaults to "default" when BALATROBOT_SETTINGS is unset, so a profile is always applied. --- src/lua/settings.lua | 57 +++++++++++++++++++++++++---------------- src/lua/utils/types.lua | 2 +- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 2f22bdcc..c36926f5 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -6,7 +6,7 @@ Environment variables read by the Lua mod: BALATROBOT_PORT - Server port (default: 12346) BALATROBOT_RENDER - Render mode: headfull|headless|ondemand (default: headfull) BALATROBOT_DEBUG - Enable debug endpoints (1/0, default: 0) - BALATROBOT_SETTINGS - Path to balatrosettings profile directory + BALATROBOT_SETTINGS - Settings profile name (bare name, e.g. "fast") ]] ---@diagnostic disable: duplicate-set-field @@ -36,29 +36,43 @@ local function deep_merge(target, source) end end ---- Apply balatrosettings profile from directory ----@param path string Absolute path to profile directory -local function apply_profile(path) +--- Apply settings profile by name +---@param name string Profile name (e.g. "default", "fast", "headless") +local function apply_profile(name) local NFS = require("nativefs") - -- Deep merge settings.lua into G.SETTINGS - local settings_src = NFS.read(path .. "/settings.lua") - assert(settings_src, "Profile not found: " .. path .. "/settings.lua") + local profile_dir = SMODS.current_mod.path .. "src/lua/profiles/" .. name .. "/" + + -- Deep merge settings.lua into G.SETTINGS (required) + local settings_src = NFS.read(profile_dir .. "settings.lua") + if not settings_src then + -- List available profiles for error message + local items = NFS.getDirectoryItems(SMODS.current_mod.path .. "src/lua/profiles/") + local available = {} + for _, item in ipairs(items) do + table.insert(available, item) + end + sendErrorMessage( + "Settings profile not found: '" .. name .. "'. Available: " .. table.concat(available, ", "), + "BB.SETTINGS" + ) + error("Settings profile not found: '" .. name .. "'") + end local profile_settings = assert(load(settings_src))() assert(type(profile_settings) == "table", "settings.lua must return a table") deep_merge(G.SETTINGS, profile_settings) - -- Deep merge balatrosettings profile data into G.PROFILES[n] - -- "1/" is a balatrosettings directory convention, not the in-game profile slot - local profile_src = NFS.read(path .. "/1/profile.lua") - assert(profile_src, "Profile not found: " .. path .. "/1/profile.lua") - local profile_data = assert(load(profile_src))() - assert(type(profile_data) == "table", "1/profile.lua must return a table") - local n = G.SETTINGS.profile or 1 - G.PROFILES[n] = G.PROFILES[n] or {} - deep_merge(G.PROFILES[n], profile_data) - - sendInfoMessage("Applied profile: " .. path, "BB.SETTINGS") + -- Deep merge profile.lua into G.PROFILES[n] (optional) + local profile_src = NFS.read(profile_dir .. "profile.lua") + if profile_src then + local profile_data = assert(load(profile_src))() + assert(type(profile_data) == "table", "profile.lua must return a table") + local n = G.SETTINGS.profile or 1 + G.PROFILES[n] = G.PROFILES[n] or {} + deep_merge(G.PROFILES[n], profile_data) + end + + sendInfoMessage("Applied profile: " .. name, "BB.SETTINGS") end --- Headless mode: disable all rendering and window operations @@ -146,10 +160,9 @@ BB_SETTINGS.setup = function() G.F_SKIP_TUTORIAL = true G.PROFILES[profile_num].all_unlocked = true - -- Apply settings profile if --settings provided - if BB_SETTINGS.settings then - apply_profile(BB_SETTINGS.settings) - end + -- Apply settings profile (default if none specified) + BB_SETTINGS.settings = BB_SETTINGS.settings or "default" + apply_profile(BB_SETTINGS.settings) -- Render mode if BB_SETTINGS.render == "headless" then diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 67d6829f..62e16917 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -277,7 +277,7 @@ ---@field port integer Port number for the HTTP server (default: 12346) ---@field render string Render mode: headfull|headless|ondemand (default: "headfull") ---@field debug boolean Whether debug mode is enabled (requires DebugPlus mod) ----@field settings string? Path to balatrosettings profile directory (nil if not provided) +---@field settings string? Settings profile name, e.g. "fast" (nil if not provided, defaults to "default" in Lua) ---@field setup fun(): boolean Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. ---@class Debug From 34e7e2f07b7ed323be7609c8acacbdd6c08fa37b Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 17:14:05 +0200 Subject: [PATCH 076/121] feat(cli): validate --settings as bare profile name Add settings_callback() with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ to reject paths, traversals, and empty strings at the CLI layer. Update help text from "Path to balatrosettings profile directory" to "Settings profile name". Update config.py docstring accordingly. Update existing tests to use bare names ("fast", "headless") instead of file paths. Add 6 new tests covering valid names, path rejection, .. traversal rejection, empty name rejection, and leading hyphen rejection. --- src/balatrobot/cli/serve.py | 19 +++++++++++++- src/balatrobot/config.py | 2 +- tests/cli/test_config.py | 20 +++++++-------- tests/cli/test_serve_cmd.py | 51 +++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 58782029..f3b0d7c2 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -2,6 +2,7 @@ import asyncio import os +import re import signal import sys from pathlib import Path @@ -17,6 +18,20 @@ # Platform choices for validation PLATFORM_CHOICES = ["darwin", "linux", "windows", "native"] +# Regex for valid settings profile names +_SETTINGS_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") + + +def settings_callback(value: str | None) -> str | None: + """Validate --settings as a bare profile name.""" + if value is None: + return None + if not _SETTINGS_RE.match(value): + raise typer.BadParameter( + f"Must be a valid profile name (alphanumeric, hyphens, underscores). Got: '{value}'" + ) + return value + class Server: """Owns the full serve lifecycle: pool start/stop, state file write/delete, @@ -97,7 +112,9 @@ def serve( ] = 1, settings: Annotated[ str | None, - typer.Option("--settings", help="Path to balatrosettings profile directory"), + typer.Option( + "--settings", help="Settings profile name", callback=settings_callback + ), ] = None, render: Annotated[ str | None, diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index 103528cf..4a45421e 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -41,7 +41,7 @@ class Config: host: str = "127.0.0.1" port: int = 12346 - # Settings profile + # Settings profile name (bare name, e.g. "fast", "headless") settings: str | None = None # Render mode diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index c9c2cc75..d2be2232 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -35,7 +35,7 @@ def test_string_passthrough(self): """String fields pass through unchanged.""" assert _parse_env_value("host", "localhost") == "localhost" assert _parse_env_value("render", "headless") == "headless" - assert _parse_env_value("settings", "/path/to/profile") == "/path/to/profile" + assert _parse_env_value("settings", "fast") == "fast" class TestConfigDefaults: @@ -138,13 +138,13 @@ def test_env_fallback(self, clean_env, monkeypatch): assert config.debug is True def test_settings_from_cli(self, clean_env): - """Settings path from CLI args.""" + """Settings profile name from CLI args.""" args = Namespace( host=None, port=None, render=None, debug=None, - settings="/path/to/profile", + settings="fast", path_balatro=None, path_lovely=None, path_love=None, @@ -153,7 +153,7 @@ def test_settings_from_cli(self, clean_env): ) config = Config.from_args(args) - assert config.settings == "/path/to/profile" + assert config.settings == "fast" class TestConfigFromEnv: @@ -187,12 +187,12 @@ def test_render_from_env(self, clean_env, monkeypatch): assert config.render == "headless" def test_settings_from_env(self, clean_env, monkeypatch): - """Settings path loaded from environment.""" - monkeypatch.setenv("BALATROBOT_SETTINGS", "/path/to/profile") + """Settings profile name loaded from environment.""" + monkeypatch.setenv("BALATROBOT_SETTINGS", "headless") config = Config.from_env() - assert config.settings == "/path/to/profile" + assert config.settings == "headless" def test_path_fields_use_new_names(self, clean_env, monkeypatch): """New path field names work from environment.""" @@ -243,11 +243,11 @@ def test_includes_render(self): assert env["BALATROBOT_RENDER"] == "headless" def test_includes_settings(self): - """Settings path is included in env output.""" - config = Config(settings="/path/to/profile") + """Settings profile name is included in env output.""" + config = Config(settings="fast") env = config.to_env() - assert env["BALATROBOT_SETTINGS"] == "/path/to/profile" + assert env["BALATROBOT_SETTINGS"] == "fast" def test_uses_new_env_var_names(self): """Uses BALATROBOT_PATH_* naming convention.""" diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index 167a2abb..ec364143 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -1,5 +1,6 @@ """Integration tests for balatrobot serve command.""" +import pytest from typer.testing import CliRunner from balatrobot.cli import app @@ -73,6 +74,56 @@ def test_serve_help(self): assert "--no-reduced-motion" not in result.output assert "--pixel-art-smoothing" not in result.output + def test_serve_settings_help_text(self): + """--settings help shows profile name description.""" + result = runner.invoke(app, ["serve", "--help"]) + assert result.exit_code == 0 + assert ( + "profile name" in result.output.lower() + or "Settings profile" in result.output + ) + + # --- Settings callback validation tests --- + + def test_serve_settings_valid_name(self): + """Valid profile names accepted by --settings.""" + from balatrobot.cli.serve import settings_callback + + assert settings_callback(None) is None + assert settings_callback("fast") == "fast" + assert settings_callback("headless") == "headless" + assert settings_callback("my-profile") == "my-profile" + assert settings_callback("my_profile") == "my_profile" + assert settings_callback("Profile123") == "Profile123" + + def test_serve_settings_rejects_path(self): + """--settings rejects paths with slashes.""" + result = runner.invoke(app, ["serve", "--settings", "/path/to/profile"]) + assert result.exit_code != 0 + + def test_serve_settings_rejects_dotdot(self): + """--settings rejects '..' traversal.""" + result = runner.invoke(app, ["serve", "--settings", "../etc/passwd"]) + assert result.exit_code != 0 + + def test_serve_settings_rejects_empty(self): + """--settings rejects empty-ish names.""" + import typer + + from balatrobot.cli.serve import settings_callback + + with pytest.raises(typer.BadParameter): + settings_callback("") + + def test_serve_settings_rejects_leading_hyphen(self): + """--settings rejects names starting with hyphen.""" + import typer + + from balatrobot.cli.serve import settings_callback + + with pytest.raises(typer.BadParameter): + settings_callback("-bad") + # --- Config.from_kwargs tests --- def test_config_from_kwargs_explicit_overrides_env(self, clean_env, monkeypatch): From 94e829bdb5ecc6356f36b47e7a9fa57edc978572 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 17:14:26 +0200 Subject: [PATCH 077/121] docs: update all docs for bundled name-based settings profiles Rewrite ADR 0001 to describe the new bundled profile architecture with hybrid Python/Lua resolution. Update cli.md to use bare profile names in all examples, remove balatrosettings references, and update the settings table row default to "default". Update SKILL.md invocation example and flag description. Fix CONTEXT.md profile path from settings/profiles/ to src/lua/profiles/. --- .agents/skills/balatrobot/SKILL.md | 5 ++-- CONTEXT.md | 2 +- docs/adr/0001-settings-redesign.md | 32 ++++++++++++++++++++--- docs/cli.md | 42 ++++++++++++++++-------------- 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index 6cbb1e50..595337ce 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -16,12 +16,13 @@ balatrobot serve --help Typical invocation: ```bash -balatrobot serve --render headless --settings ~/balatrosettings/profiles/fast --debug +balatrobot serve --render headless --settings fast --debug ``` Key flags: + - `--render [headfull|headless|ondemand]` — rendering mode (default: headfull) -- `--settings PATH` — path to balatrosettings profile directory +- `--settings NAME` — settings profile name (default: "default") - `--debug` — enable debug endpoints - `--num` — number of instances - `--path-*` — path overrides (`--path-balatro`, `--path-lovely`, `--path-love`, `--path-logs`) diff --git a/CONTEXT.md b/CONTEXT.md index 20d5f344..3dab64cb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,7 +27,7 @@ Glossary of terms used in the BalatroBot project. | **pack** / **booster** / **booster pack** | Same thing. A purchasable pack of cards you open and choose from. | | **test fixture** | A JSON file of API call sequences that reproduces a specific game state. Not a pytest fixture. Generated by `make fixtures` and loaded by tests. | | **`dev` marker** | `@pytest.mark.dev` — tags tests currently being developed. Run with `pytest -m dev` to isolate. Remove when done. Ephemeral, not permanent. | -| **profile** | Overloaded by design — meaning is clear from context. Can refer to: (1) a **Balatro in-game profile** (save slot 1–3 with a name like "BalatroBot"), or (2) a **balatrosettings profile** (a directory of Lua files that overrides `G.SETTINGS` and `G.PROFILES` via `--settings`). | +| **profile** | Overloaded by design — meaning is clear from context. Can refer to: (1) a **Balatro in-game profile** (save slot 1–3 with a name like "BalatroBot"), or (2) a **settings profile** (a named directory under `src/lua/profiles/` that overrides `G.SETTINGS` and `G.PROFILES` via `--settings`). | | **BalatroBot profile** | A Balatro in-game profile named exactly `BalatroBot`. Required for the mod to activate — if no profile with this name exists, the HTTP server does not start and no settings overrides are applied. | | **render mode** | How BalatroBot handles rendering: `headfull` (normal rendering, default), `headless` (no rendering, no window), or `ondemand` (render only when triggered by an API call). Set via `--render` or `BALATROBOT_RENDER`. | | **endpoint** | A single API operation (e.g. `play`, `start`, `health`). Each endpoint is a Lua module in `src/lua/endpoints/`. Called "method" in JSON-RPC contexts and exposed as `balatrobot api <endpoint>` in the CLI. | diff --git a/docs/adr/0001-settings-redesign.md b/docs/adr/0001-settings-redesign.md index c211994f..f5d2308e 100644 --- a/docs/adr/0001-settings-redesign.md +++ b/docs/adr/0001-settings-redesign.md @@ -1,11 +1,37 @@ -# Settings via balatrosettings profiles instead of individual CLI flags +# Settings profiles bundled in the mod tree -BalatroBot v2 replaces 12+ individual CLI flags/env vars for game settings (`--fast`, `--gamespeed`, `--fps-cap`, `--animation-fps`, `--audio`, `--no-reduced-motion`, `--pixel-art-smoothing`, etc.) with a single `--settings` flag that points to a balatrosettings profile directory. The mod is gated on the in-game profile being named exactly "BalatroBot" — if no such profile exists, the HTTP server does not start and no overrides are applied. Render modes are consolidated into a single `--render [headfull|headless|ondemand]` enum. Only two hardcoded overrides remain: `G.F_SKIP_TUTORIAL = true` and `all_unlocked = true`, applied only when the "BalatroBot" profile is detected. +BalatroBot uses bundled **settings profiles** to configure Balatro's game settings (speed, graphics, audio, window, etc.). Profiles live inside the Lua mod tree at `src/lua/profiles/` and are selected by bare name via `--settings fast` or `BALATROBOT_SETTINGS=fast`. A `default` profile is always applied when no profile is specified. **Considered Options:** - **Individual flags** (v1 approach): flexible and composable, but the surface grew to 19 flags. Most were thin wrappers around `G.SETTINGS` fields that Balatro already has a mechanism for. Maintenance burden grew with every new setting. -- **balatrosettings profile** (chosen): reuses the existing balatrosettings format (plain `return {...}` Lua files). A profile is a directory with `settings.lua` and `1/profile.lua`, deep-merged into `G.SETTINGS` and `G.PROFILES`. Simpler CLI surface (one flag), and profiles are shareable across users. +- **External profile directory** (v2 initial approach): pointed `--settings` to an external `balatrosettings` repo checkout. Worked but required users to clone and maintain a separate repo, used absolute paths on the CLI, and introduced platform-specific path issues. +- **Bundled profiles with name-based resolution** (chosen): profiles are directories inside `src/lua/profiles/` with `settings.lua` (required) and `profile.lua` (optional). The CLI accepts bare names; the Lua mod resolves paths via `SMODS.current_mod.path`. Simpler UX, no external dependencies, portable across platforms. - **Sidecar Lua file in profile dir**: a `balatrobot.lua` alongside `settings.lua` that could patch `G` globals directly. Rejected — arbitrary Lua execution defeats the simplicity goal and is hard to validate. +**Architecture: hybrid resolution** + +- **Python side**: lightweight validation only. A typer callback checks that the `--settings` value matches `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` (no `/`, `..`, etc.). Does NOT resolve paths or check if the profile exists. +- **Lua side**: full resolution. Discovers the mod directory via `SMODS.current_mod.path`, looks up `<moddir>/src/lua/profiles/<name>/`, loads files. If not found: sends error message listing available profiles, then aborts mod loading (HTTP server does not start). + +**Profile structure:** + +``` +src/lua/profiles/ +├── default/ +│ ├── settings.lua # required — merged into G.SETTINGS +│ └── profile.lua # optional — merged into G.PROFILES[n] +├── fast/ +│ ├── settings.lua +│ └── profile.lua +└── headless/ + ├── settings.lua + └── profile.lua +``` + +- `settings.lua` — returns a Lua table deep-merged into `G.SETTINGS`. Required. +- `profile.lua` — returns a Lua table deep-merged into `G.PROFILES[n]`. Optional; if missing, the merge is skipped. + +**`default` profile:** Always applied when `--settings` is omitted or `BALATROBOT_SETTINGS` is unset. The fallback to `"default"` happens in the Lua `settings.lua`, not in Python. No escape hatch (`--settings none` does not exist). + **Why the "BalatroBot" profile gate:** BalatroBot needs `all_unlocked = true` and tutorial skipped to function as a bot platform. These can't be profile settings because they affect meta state that's consumed before SMODS loads (see boot sequence: `init_item_prototypes` runs at step 7, SMODS at step 8). Rather than hooking earlier via Lovely patches, we gate on the in-game profile name — the user creates a dedicated "BalatroBot" profile, and the mod only activates when that profile is selected. This protects the user's real save data (no accidental overwrites) and makes the activation condition visible in the game's own UI. diff --git a/docs/cli.md b/docs/cli.md index c6898c9f..fd355202 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -35,12 +35,12 @@ All options can be set via CLI flags or environment variables. CLI flags overrid | CLI Flag | Environment Variable | Default | Description | | --------------------- | ------------------------- | ------------- | -------------------------------------------------- | -| `--settings PATH` | `BALATROBOT_SETTINGS` | *(none)* | Path to balatrosettings profile directory | +| `--settings NAME` | `BALATROBOT_SETTINGS` | `default` | Settings profile name | | `--render MODE` | `BALATROBOT_RENDER` | `headfull` | Render mode: `headfull`, `headless`, or `ondemand` | | `--debug` | `BALATROBOT_DEBUG` | `0` | Enable debug mode (requires DebugPlus mod) | | `--host HOST` | `BALATROBOT_HOST` | `127.0.0.1` | Server hostname | | `--port PORT` | `BALATROBOT_PORT` | `12346` | Server port | -| `--num N` | - | `1` | Number of instances to start (CLI only) | +| `--num N` | - | `1` | Number of instances to start (CLI only) | | `--path-balatro PATH` | `BALATROBOT_PATH_BALATRO` | auto-detected | Path to Balatro game directory | | `--path-lovely PATH` | `BALATROBOT_PATH_LOVELY` | auto-detected | Path to lovely library (dll/so/dylib) | | `--path-love PATH` | `BALATROBOT_PATH_LOVE` | auto-detected | Path to game launcher executable | @@ -58,18 +58,22 @@ All options can be set via CLI flags or environment variables. CLI flags overrid ### Settings Profiles -The `--settings` flag points to a [balatrosettings](https://github.com/S1M0N38/balatrosettings) profile directory containing `settings.lua` and `1/profile.lua`. These are deep-merged into Balatro's `G.SETTINGS` and `G.PROFILES` tables, allowing partial overrides. +BalatroBot bundles settings profiles that configure Balatro's game settings (speed, graphics, audio, window, etc.). Use `--settings` with a bare profile name: -Example profile structure: +```bash +# Use the "fast" profile (max speed, no audio, minimal graphics) +uvx balatrobot serve --settings fast +# Use the "headless" profile (silent, no graphics) +uvx balatrobot serve --settings headless --render headless + +# Default profile is applied when --settings is omitted +uvx balatrobot serve ``` -my-profile/ - settings.lua # Returns table merged into G.SETTINGS - 1/ - profile.lua # Returns table merged into G.PROFILES[1] -``` -**Note:** When using `--settings`, both files must exist and return valid Lua tables. The profile load fails fast with a clear error if files are missing or malformed. +Available profiles: `default`, `fast`, `headless`. + +The profile contains `settings.lua` (merged into `G.SETTINGS`) and optionally `profile.lua` (merged into `G.PROFILES`). Profiles live in `src/lua/profiles/<name>/`. Custom profiles can be added by creating a new directory with a `settings.lua` file. ## api Command @@ -128,14 +132,14 @@ On error, prints `Error: NAME - message` to stderr (exit code 1). ### Basic Usage ```bash -# Start with default settings (headfull, no profile) +# Start with default settings profile (headfull) uvx balatrobot serve -# Start headless with a fast profile -uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless +# Start headless with the fast profile +uvx balatrobot serve --settings fast --render headless # Start with debug mode (requires DebugPlus mod) -uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --debug +uvx balatrobot serve --settings fast --debug ``` ### Custom Configuration @@ -148,7 +152,7 @@ uvx balatrobot serve --port 8080 uvx balatrobot serve --path-balatro /path/to/Balatro # On-demand rendering for screenshot capture -uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render ondemand +uvx balatrobot serve --settings headless --render ondemand ``` ## Examples with Environment Variables @@ -159,7 +163,7 @@ uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render ond # Configure via environment variables export BALATROBOT_PORT=8080 export BALATROBOT_RENDER=headless -export BALATROBOT_SETTINGS=~/balatrosettings/profiles/headless +export BALATROBOT_SETTINGS=headless # Launch with defaults from env vars uvx balatrobot serve @@ -205,7 +209,7 @@ The `windows` platform launches Balatro via Steam on Windows. The CLI auto-detec ```powershell # Auto-detects paths -uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless +uvx balatrobot serve --settings headless --render headless # Or specify custom paths uvx balatrobot serve --path-love "C:\Custom\Path\Balatro.exe" --path-lovely "C:\Custom\Path\version.dll" @@ -232,7 +236,7 @@ The `darwin` platform launches Balatro via Steam on macOS. The CLI auto-detects ```bash # Auto-detects paths -uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless +uvx balatrobot serve --settings headless --render headless # Or specify custom paths uvx balatrobot serve --path-love "/path/to/love" --path-lovely "/path/to/liblovely.dylib" @@ -259,7 +263,7 @@ The `linux` platform launches Balatro via Steam Proton. The CLI auto-detects Ste ```bash # Auto-detects paths -uvx balatrobot serve --settings ~/balatrosettings/profiles/headless --render headless +uvx balatrobot serve --settings headless --render headless # Or specify custom paths uvx balatrobot serve --path-love /path/to/proton --path-balatro /path/to/Balatro From 5127eb86cfed8f91d5d04236cab155d65277c07b Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 18:28:13 +0200 Subject: [PATCH 078/121] fix(mod): validate settings profile name and wire verbose flag Assert profile names match a safe pattern (alphanumeric, dash, underscore) to prevent path traversal. Set G.F_VERBOSE from the debug setting so Balatro's internal logging respects the profile. --- src/lua/settings.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index c36926f5..d2a4708c 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -39,6 +39,7 @@ end --- Apply settings profile by name ---@param name string Profile name (e.g. "default", "fast", "headless") local function apply_profile(name) + assert(name:match("^[a-zA-Z0-9][a-zA-Z0-9_-]*$"), "Invalid profile name: " .. name) local NFS = require("nativefs") local profile_dir = SMODS.current_mod.path .. "src/lua/profiles/" .. name .. "/" @@ -158,6 +159,7 @@ BB_SETTINGS.setup = function() -- Hardcoded overrides for bot operation G.F_SKIP_TUTORIAL = true + G.F_VERBOSE = BB_SETTINGS.debug G.PROFILES[profile_num].all_unlocked = true -- Apply settings profile (default if none specified) From e4406e5fdff8039d8fadc6729d3d2df3ed516289 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 18:28:17 +0200 Subject: [PATCH 079/121] refactor(mod): normalize env var names and render comments Rename BALATROBOT_LOGS_PATH to BALATROBOT_PATH_LOGS to match the BALATROBOT_PATH_* naming convention. Update render_on_api comment to ondemand to reflect the actual mode name. --- src/lua/core/dispatcher.lua | 2 +- src/lua/core/server.lua | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index d11172d4..a73f49cc 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -132,7 +132,7 @@ end ---@param request Request.Server function BB_DISPATCHER.dispatch(request) - -- Trigger render for this frame if render_on_api mode is enabled + -- Trigger render for this frame if ondemand mode is enabled if BB_RENDER ~= nil then BB_RENDER = true end diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index 08d1533b..b20c28c3 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -135,8 +135,8 @@ function BB_SERVER.init() sendInfoMessage("HTTP server listening on http://" .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") - -- Open JSONL recording files if BALATROBOT_LOGS_PATH is set - local logs_path = os.getenv("BALATROBOT_LOGS_PATH") + -- Open JSONL recording files if BALATROBOT_PATH_LOGS is set + local logs_path = os.getenv("BALATROBOT_PATH_LOGS") if logs_path and logs_path ~= "" then local req_path = logs_path .. "/" .. BB_SERVER.port .. ".req.jsonl" local res_path = logs_path .. "/" .. BB_SERVER.port .. ".res.jsonl" From eb19b82d0c8a166ff0fa3739a68b423de6110f4c Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 18:28:21 +0200 Subject: [PATCH 080/121] fix(mod): use windowed mode in default settings profile Replace Borderless with a fixed resolution with Windowed mode and disable vsync. The default profile should not override the users screen resolution. --- src/lua/profiles/default/settings.lua | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lua/profiles/default/settings.lua b/src/lua/profiles/default/settings.lua index 153c8435..5df88919 100644 --- a/src/lua/profiles/default/settings.lua +++ b/src/lua/profiles/default/settings.lua @@ -6,11 +6,8 @@ return { ["volume"] = 50, }, ["WINDOW"] = { - ["screenmode"] = "Borderless", - ["screen_res"] = { - ["h"] = 480, - ["w"] = 820, - }, + ["screenmode"] = "Windowed", + ["vsync"] = 0, }, ["crashreports"] = false, ["language"] = "en-us", From d72cda7d4373898f9178c1a32a93deed0e5b8912 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 18:28:25 +0200 Subject: [PATCH 081/121] docs: update contributing guide env var examples Replace deprecated env vars (BALATROBOT_FAST, BALATROBOT_HEADLESS, etc.) with the new BALATROBOT_SETTINGS and BALATROBOT_RENDER conventions. Use BALATROBOT_PATH_* naming for paths. --- docs/contributing.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 945b6685..473efa20 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -32,13 +32,11 @@ export PYTHONPATH="${PWD}/src:${PYTHONPATH}" export PYTHONPATH="${PWD}/tests:${PYTHONPATH}" # BALATROBOT env vars -export BALATROBOT_FAST=1 +export BALATROBOT_SETTINGS=fast +export BALATROBOT_RENDER=headless export BALATROBOT_DEBUG=1 -export BALATROBOT_LOVE_PATH='/path/to/Balatro/love' -export BALATROBOT_LOVELY_PATH='/path/to/liblovely.dylib' -export BALATROBOT_RENDER_ON_API=0 -export BALATROBOT_HEADLESS=1 -export BALATROBOT_AUDIO=0 +export BALATROBOT_PATH_LOVE='/path/to/Balatro/love' +export BALATROBOT_PATH_LOVELY='/path/to/liblovely.dylib' ``` **Setup:** Install [direnv](https://direnv.net/), then create `.envrc` in the project root with the above configuration, updating paths for your system. From 08dd3b8127a1be8a6de480a2b7beca47cb3c06e4 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 19:04:44 +0200 Subject: [PATCH 082/121] refactor(mod): replace headless profile with turbo and light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless profile was redundant with --render headless. Replace it with two new profiles that serve distinct use cases: - turbo: 128x speed, no audio, minimal graphics, vsync off — for automated bot sessions that need maximum throughput - light: normal speed, no audio, minimal graphics, vsync off — for manual play without audio/visual distractions Also rewrite the default and fast profiles to match actual Balatro defaults rather than arbitrary values, so they behave predictably as baselines. --- src/lua/profiles/default/settings.lua | 20 ++++++++---- src/lua/profiles/fast/settings.lua | 32 ++++++++++--------- .../profiles/{headless => light}/profile.lua | 0 .../profiles/{headless => light}/settings.lua | 24 ++++++++------ src/lua/profiles/turbo/profile.lua | 1 + src/lua/profiles/turbo/settings.lua | 25 +++++++++++++++ 6 files changed, 72 insertions(+), 30 deletions(-) rename src/lua/profiles/{headless => light}/profile.lua (100%) rename src/lua/profiles/{headless => light}/settings.lua (66%) create mode 100644 src/lua/profiles/turbo/profile.lua create mode 100644 src/lua/profiles/turbo/settings.lua diff --git a/src/lua/profiles/default/settings.lua b/src/lua/profiles/default/settings.lua index 5df88919..2505fc98 100644 --- a/src/lua/profiles/default/settings.lua +++ b/src/lua/profiles/default/settings.lua @@ -1,17 +1,25 @@ return { - ["GAMESPEED"] = 4, + ["GAMESPEED"] = 1, ["SOUND"] = { - ["game_sounds_volume"] = 100, - ["music_volume"] = 100, ["volume"] = 50, + ["music_volume"] = 100, + ["game_sounds_volume"] = 100, + }, + ["GRAPHICS"] = { + ["shadows"] = "On", + ["texture_scaling"] = 2, + ["crt"] = 70, + ["bloom"] = 1, }, ["WINDOW"] = { ["screenmode"] = "Windowed", - ["vsync"] = 0, + ["vsync"] = 1, }, - ["crashreports"] = false, - ["language"] = "en-us", + ["screenshake"] = 50, + ["reduced_motion"] = false, ["skip_splash"] = "Yes", ["tutorial_complete"] = true, ["current_setup"] = "New Run", + ["crashreports"] = false, + ["language"] = "en-us", } diff --git a/src/lua/profiles/fast/settings.lua b/src/lua/profiles/fast/settings.lua index 2f2c58f1..4403c4a5 100644 --- a/src/lua/profiles/fast/settings.lua +++ b/src/lua/profiles/fast/settings.lua @@ -1,23 +1,25 @@ return { - ["GAMESPEED"] = 128, + ["GAMESPEED"] = 4, + ["SOUND"] = { + ["volume"] = 50, + ["music_volume"] = 100, + ["game_sounds_volume"] = 100, + }, ["GRAPHICS"] = { - ["shadows"] = "Off", - ["texture_scaling"] = 1, - ["crt"] = 0, - ["bloom"] = 0, + ["shadows"] = "On", + ["texture_scaling"] = 2, + ["crt"] = 70, + ["bloom"] = 1, }, - ["SOUND"] = { - ["volume"] = 0, - ["music_volume"] = 0, - ["game_sounds_volume"] = 0, + ["WINDOW"] = { + ["screenmode"] = "Windowed", + ["vsync"] = 1, }, + ["screenshake"] = 50, + ["reduced_motion"] = false, ["skip_splash"] = "Yes", ["tutorial_complete"] = true, ["current_setup"] = "New Run", - ["screenshake"] = 0, - ["reduced_motion"] = true, - ["WINDOW"] = { - ["screenmode"] = "Windowed", - ["vsync"] = 0, - }, + ["crashreports"] = false, + ["language"] = "en-us", } diff --git a/src/lua/profiles/headless/profile.lua b/src/lua/profiles/light/profile.lua similarity index 100% rename from src/lua/profiles/headless/profile.lua rename to src/lua/profiles/light/profile.lua diff --git a/src/lua/profiles/headless/settings.lua b/src/lua/profiles/light/settings.lua similarity index 66% rename from src/lua/profiles/headless/settings.lua rename to src/lua/profiles/light/settings.lua index 83a85a23..ce9ea414 100644 --- a/src/lua/profiles/headless/settings.lua +++ b/src/lua/profiles/light/settings.lua @@ -1,19 +1,25 @@ return { - ["GAMESPEED"] = 4, - ["GRAPHICS"] = { - ["shadows"] = "Off", - }, + ["GAMESPEED"] = 1, ["SOUND"] = { ["volume"] = 0, ["music_volume"] = 0, ["game_sounds_volume"] = 0, }, - ["skip_splash"] = "Yes", - ["tutorial_complete"] = true, - ["current_setup"] = "New Run", - ["screenshake"] = 0, - ["reduced_motion"] = true, + ["GRAPHICS"] = { + ["shadows"] = "Off", + ["texture_scaling"] = 1, + ["crt"] = 0, + ["bloom"] = 1, + }, ["WINDOW"] = { + ["screenmode"] = "Windowed", ["vsync"] = 0, }, + ["screenshake"] = 0, + ["reduced_motion"] = true, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["crashreports"] = false, + ["language"] = "en-us", } diff --git a/src/lua/profiles/turbo/profile.lua b/src/lua/profiles/turbo/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/turbo/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/turbo/settings.lua b/src/lua/profiles/turbo/settings.lua new file mode 100644 index 00000000..0af087d4 --- /dev/null +++ b/src/lua/profiles/turbo/settings.lua @@ -0,0 +1,25 @@ +return { + ["GAMESPEED"] = 128, + ["SOUND"] = { + ["volume"] = 0, + ["music_volume"] = 0, + ["game_sounds_volume"] = 0, + }, + ["GRAPHICS"] = { + ["shadows"] = "Off", + ["texture_scaling"] = 1, + ["crt"] = 0, + ["bloom"] = 1, + }, + ["WINDOW"] = { + ["screenmode"] = "Windowed", + ["vsync"] = 0, + }, + ["screenshake"] = 0, + ["reduced_motion"] = true, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["crashreports"] = false, + ["language"] = "en-us", +} From 87201330470951bfee59bd0cc41bc800c5e9ff19 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 19:04:50 +0200 Subject: [PATCH 083/121] docs: update profile references from headless to turbo/light Replace all references to the removed headless profile with the new turbo and light profiles in the ADR and CLI docs. Update example commands that combined --settings headless with --render headless, since those are now independent concerns. --- docs/adr/0001-settings-redesign.md | 9 ++++++--- docs/cli.md | 17 +++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/adr/0001-settings-redesign.md b/docs/adr/0001-settings-redesign.md index f5d2308e..64550622 100644 --- a/docs/adr/0001-settings-redesign.md +++ b/docs/adr/0001-settings-redesign.md @@ -21,10 +21,13 @@ src/lua/profiles/ ├── default/ │ ├── settings.lua # required — merged into G.SETTINGS │ └── profile.lua # optional — merged into G.PROFILES[n] -├── fast/ +└── fast/ + ├── settings.lua + └── profile.lua +├── turbo/ │ ├── settings.lua -│ └── profile.lua -└── headless/ + └── profile.lua +└── light/ ├── settings.lua └── profile.lua ``` diff --git a/docs/cli.md b/docs/cli.md index fd355202..9d3894a0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -64,14 +64,11 @@ BalatroBot bundles settings profiles that configure Balatro's game settings (spe # Use the "fast" profile (max speed, no audio, minimal graphics) uvx balatrobot serve --settings fast -# Use the "headless" profile (silent, no graphics) -uvx balatrobot serve --settings headless --render headless - # Default profile is applied when --settings is omitted uvx balatrobot serve ``` -Available profiles: `default`, `fast`, `headless`. +Available profiles: `default`, `fast`, `turbo`, `light`. The profile contains `settings.lua` (merged into `G.SETTINGS`) and optionally `profile.lua` (merged into `G.PROFILES`). Profiles live in `src/lua/profiles/<name>/`. Custom profiles can be added by creating a new directory with a `settings.lua` file. @@ -152,7 +149,7 @@ uvx balatrobot serve --port 8080 uvx balatrobot serve --path-balatro /path/to/Balatro # On-demand rendering for screenshot capture -uvx balatrobot serve --settings headless --render ondemand +uvx balatrobot serve --render ondemand ``` ## Examples with Environment Variables @@ -163,7 +160,7 @@ uvx balatrobot serve --settings headless --render ondemand # Configure via environment variables export BALATROBOT_PORT=8080 export BALATROBOT_RENDER=headless -export BALATROBOT_SETTINGS=headless +export BALATROBOT_SETTINGS=fast # Launch with defaults from env vars uvx balatrobot serve @@ -209,7 +206,7 @@ The `windows` platform launches Balatro via Steam on Windows. The CLI auto-detec ```powershell # Auto-detects paths -uvx balatrobot serve --settings headless --render headless +uvx balatrobot serve --render headless # Or specify custom paths uvx balatrobot serve --path-love "C:\Custom\Path\Balatro.exe" --path-lovely "C:\Custom\Path\version.dll" @@ -236,7 +233,7 @@ The `darwin` platform launches Balatro via Steam on macOS. The CLI auto-detects ```bash # Auto-detects paths -uvx balatrobot serve --settings headless --render headless +uvx balatrobot serve --render headless # Or specify custom paths uvx balatrobot serve --path-love "/path/to/love" --path-lovely "/path/to/liblovely.dylib" @@ -263,7 +260,7 @@ The `linux` platform launches Balatro via Steam Proton. The CLI auto-detects Ste ```bash # Auto-detects paths -uvx balatrobot serve --settings headless --render headless +uvx balatrobot serve --render headless # Or specify custom paths uvx balatrobot serve --path-love /path/to/proton --path-balatro /path/to/Balatro @@ -327,4 +324,4 @@ uvx balatrobot serve --platform native --path-balatro /path/to/balatro/source **Port in use**: Change the port with `--port` or set `BALATROBOT_PORT` to a different value. -**Game crashes**: Try running in headless mode with `--render headless` and a minimal settings profile. +**Game crashes**: Try running in headless mode with `--render headless` and the `fast` profile (`--settings fast`). From 066f197f11cea9063804c7c4cafc61d584293e67 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 19:04:55 +0200 Subject: [PATCH 084/121] refactor(mod): update profile name examples in code comments Update inline comments and docstrings that listed profile name examples to include turbo and light instead of headless. --- src/balatrobot/config.py | 2 +- src/lua/settings.lua | 4 ++-- src/lua/utils/types.lua | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index 4a45421e..5b416aa7 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -41,7 +41,7 @@ class Config: host: str = "127.0.0.1" port: int = 12346 - # Settings profile name (bare name, e.g. "fast", "headless") + # Settings profile name (bare name, e.g. "fast", "turbo", "light") settings: str | None = None # Render mode diff --git a/src/lua/settings.lua b/src/lua/settings.lua index d2a4708c..4634f35c 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -6,7 +6,7 @@ Environment variables read by the Lua mod: BALATROBOT_PORT - Server port (default: 12346) BALATROBOT_RENDER - Render mode: headfull|headless|ondemand (default: headfull) BALATROBOT_DEBUG - Enable debug endpoints (1/0, default: 0) - BALATROBOT_SETTINGS - Settings profile name (bare name, e.g. "fast") + BALATROBOT_SETTINGS - Settings profile name (bare name, e.g. "fast", "turbo", "light") ]] ---@diagnostic disable: duplicate-set-field @@ -37,7 +37,7 @@ local function deep_merge(target, source) end --- Apply settings profile by name ----@param name string Profile name (e.g. "default", "fast", "headless") +---@param name string Profile name (e.g. "default", "fast", "turbo", "light") local function apply_profile(name) assert(name:match("^[a-zA-Z0-9][a-zA-Z0-9_-]*$"), "Invalid profile name: " .. name) local NFS = require("nativefs") diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 62e16917..ecf147a5 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -277,7 +277,7 @@ ---@field port integer Port number for the HTTP server (default: 12346) ---@field render string Render mode: headfull|headless|ondemand (default: "headfull") ---@field debug boolean Whether debug mode is enabled (requires DebugPlus mod) ----@field settings string? Settings profile name, e.g. "fast" (nil if not provided, defaults to "default" in Lua) +---@field settings string? Settings profile name, e.g. "fast", "turbo", "light" (nil if not provided, defaults to "default" in Lua) ---@field setup fun(): boolean Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. ---@class Debug From 7e36ca95b970998f00b67d20eccf97c2c66acded Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 20:40:24 +0200 Subject: [PATCH 085/121] feat(cli): remove --port flag, make port allocation ephemeral Remove the user-configurable --port CLI flag and BALATROBOT_PORT environment variable. Ports are now allocated ephemerally by the OS, eliminating port conflicts and simplifying the config surface. The port is still passed to the Lua mod as BALATROBOT_PORT internally, but it is assigned by the launcher at runtime rather than by the user. Also change path_logs default from "logs" to None so the fallback in instance.py ("logs") is the single source of truth, avoiding a duplicate default in config. --- docs/cli.md | 11 +--- docs/installation.md | 2 +- src/balatrobot/cli/serve.py | 4 +- src/balatrobot/config.py | 26 ++-------- src/balatrobot/instance.py | 2 +- src/lua/settings.lua | 2 +- tests/cli/test_config.py | 101 +----------------------------------- tests/cli/test_serve_cmd.py | 10 ++-- 8 files changed, 15 insertions(+), 143 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 9d3894a0..e3e0c5c7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -39,7 +39,6 @@ All options can be set via CLI flags or environment variables. CLI flags overrid | `--render MODE` | `BALATROBOT_RENDER` | `headfull` | Render mode: `headfull`, `headless`, or `ondemand` | | `--debug` | `BALATROBOT_DEBUG` | `0` | Enable debug mode (requires DebugPlus mod) | | `--host HOST` | `BALATROBOT_HOST` | `127.0.0.1` | Server hostname | -| `--port PORT` | `BALATROBOT_PORT` | `12346` | Server port | | `--num N` | - | `1` | Number of instances to start (CLI only) | | `--path-balatro PATH` | `BALATROBOT_PATH_BALATRO` | auto-detected | Path to Balatro game directory | | `--path-lovely PATH` | `BALATROBOT_PATH_LOVELY` | auto-detected | Path to lovely library (dll/so/dylib) | @@ -142,9 +141,6 @@ uvx balatrobot serve --settings fast --debug ### Custom Configuration ```bash -# Use a different port -uvx balatrobot serve --port 8080 - # Custom Balatro installation uvx balatrobot serve --path-balatro /path/to/Balatro @@ -158,21 +154,16 @@ uvx balatrobot serve --render ondemand ```bash # Configure via environment variables -export BALATROBOT_PORT=8080 export BALATROBOT_RENDER=headless export BALATROBOT_SETTINGS=fast # Launch with defaults from env vars uvx balatrobot serve - -# CLI flags override env vars -uvx balatrobot serve --port 9000 # Uses port 9000, not 8080 ``` **Windows PowerShell:** ```powershell -$env:BALATROBOT_PORT = "8080" $env:BALATROBOT_RENDER = "headless" uvx balatrobot serve ``` @@ -322,6 +313,6 @@ uvx balatrobot serve --platform native --path-balatro /path/to/balatro/source **Mod not loading**: Verify that Lovely Injector and Steamodded are installed correctly. Ensure you have a Balatro profile named `"BalatroBot"` and it is selected. -**Port in use**: Change the port with `--port` or set `BALATROBOT_PORT` to a different value. +**Port in use**: Ports are allocated ephemerally. If you need a specific port, adjust your firewall rules to allow the ephemeral range. **Game crashes**: Try running in headless mode with `--render headless` and the `fast` profile (`--settings fast`). diff --git a/docs/installation.md b/docs/installation.md index d3bafa94..9f0a8a33 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -68,7 +68,7 @@ Expected response: - **Connection refused**: Ensure Balatro is running and the mod loaded successfully - **Mod not loading**: Check that Lovely and Steamodded are installed correctly -- **Port in use**: Use `uvx balatrobot serve --port PORT` to specify a different port +- **Port in use**: Ports are allocated ephemerally. Check the state file or logs for the actual port assigned. - **No display server (Linux)**: Ensure `DISPLAY` or `WAYLAND_DISPLAY` is set in your environment For more troubleshooting help, see the [CLI Reference](cli.md). diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index f3b0d7c2..78263e89 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -124,7 +124,6 @@ def serve( bool | None, typer.Option("--debug", help="Enable debug endpoints") ] = None, host: Annotated[str | None, typer.Option("--host", help="Server hostname")] = None, - port: Annotated[int | None, typer.Option("--port", help="Server port")] = None, path_balatro: Annotated[ str | None, typer.Option("--path-balatro", help="Path to Balatro directory") ] = None, @@ -164,7 +163,6 @@ def serve( render=render, debug=debug, host=host, - port=port, path_balatro=path_balatro, path_lovely=path_lovely, path_love=path_love, @@ -194,7 +192,7 @@ async def _serve(config: Config, n: int) -> None: for i, info in enumerate(pool.instances): typer.echo(f"Instance [{i}]: {info.url}") typer.echo( - f"Session: {pool.session_name} | Logs: {config.path_logs}/{pool.session_name}/" + f"Session: {pool.session_name} | Logs: {config.path_logs or 'logs'}/{pool.session_name}/" ) typer.echo("Press Ctrl+C to stop.") await server.run() diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index 5b416aa7..e63c7e24 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -6,7 +6,6 @@ ENV_MAP: dict[str, str] = { "host": "BALATROBOT_HOST", - "port": "BALATROBOT_PORT", "render": "BALATROBOT_RENDER", "debug": "BALATROBOT_DEBUG", "settings": "BALATROBOT_SETTINGS", @@ -19,17 +18,13 @@ BOOL_FIELDS = frozenset({"debug"}) -INT_FIELDS = frozenset({"port"}) - RENDER_CHOICES = frozenset({"headfull", "headless", "ondemand"}) -def _parse_env_value(field: str, value: str) -> str | int | bool: - """Convert env var string to proper type. Raises ValueError on invalid int.""" +def _parse_env_value(field: str, value: str) -> str | bool: + """Convert env var string to proper type.""" if field in BOOL_FIELDS: return value in ("1", "true") - if field in INT_FIELDS: - return int(value) return value @@ -55,7 +50,7 @@ class Config: path_lovely: str | None = None path_love: str | None = None platform: str | None = None - path_logs: str = "logs" + path_logs: str | None = None def __post_init__(self) -> None: if self.render not in RENDER_CHOICES: @@ -64,20 +59,6 @@ def __post_init__(self) -> None: f"Choose from: {', '.join(sorted(RENDER_CHOICES))}" ) - @classmethod - def from_args(cls, args) -> Self: - """Create Config from CLI args with env var fallback.""" - kwargs: dict[str, Any] = {} - - for field, env_var in ENV_MAP.items(): - cli_val = getattr(args, field, None) - if cli_val is not None: - kwargs[field] = cli_val - elif (env_val := os.environ.get(env_var)) is not None: - kwargs[field] = _parse_env_value(field, env_val) - - return cls(**kwargs) - @classmethod def from_env(cls) -> Self: """Create Config from environment variables only.""" @@ -114,4 +95,5 @@ def to_env(self) -> dict[str, str]: env[env_var] = "1" else: env[env_var] = str(value) + env["BALATROBOT_PORT"] = str(self.port) return env diff --git a/src/balatrobot/instance.py b/src/balatrobot/instance.py index 170889db..585d0972 100644 --- a/src/balatrobot/instance.py +++ b/src/balatrobot/instance.py @@ -110,7 +110,7 @@ async def start(self) -> None: session_name = self._session_name or datetime.now().strftime( "%Y-%m-%dT%H-%M-%S" ) - session_dir = Path(self._config.path_logs) / session_name + session_dir = Path(self._config.path_logs or "logs") / session_name session_dir.mkdir(parents=True, exist_ok=True) self._log_path = session_dir / f"{self._config.port}.log" diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 4634f35c..5f64ab58 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -3,7 +3,7 @@ BalatroBot v2 settings — profile-based configuration. Environment variables read by the Lua mod: BALATROBOT_HOST - Server hostname (default: 127.0.0.1) - BALATROBOT_PORT - Server port (default: 12346) + BALATROBOT_PORT - Server port (set internally by launcher, default: 12346) BALATROBOT_RENDER - Render mode: headfull|headless|ondemand (default: headfull) BALATROBOT_DEBUG - Enable debug endpoints (1/0, default: 0) BALATROBOT_SETTINGS - Settings profile name (bare name, e.g. "fast", "turbo", "light") diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index d2be2232..25b8c97e 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -1,7 +1,5 @@ """Tests for balatrobot.config module.""" -from argparse import Namespace - import pytest from balatrobot.config import RENDER_CHOICES, Config, _parse_env_value @@ -21,16 +19,6 @@ def test_bool_false_values(self): assert _parse_env_value("debug", "false") is False assert _parse_env_value("debug", "yes") is False - def test_int_valid(self): - """Integer fields parse valid numbers.""" - assert _parse_env_value("port", "12346") == 12346 - assert _parse_env_value("port", "9999") == 9999 - - def test_int_invalid(self): - """Integer fields raise ValueError for invalid input.""" - with pytest.raises(ValueError): - _parse_env_value("port", "abc") - def test_string_passthrough(self): """String fields pass through unchanged.""" assert _parse_env_value("host", "localhost") == "localhost" @@ -50,7 +38,7 @@ def test_defaults(self, clean_env): assert config.render == "headfull" assert config.debug is False assert config.settings is None - assert config.path_logs == "logs" + assert config.path_logs is None assert config.path_balatro is None assert config.path_lovely is None assert config.path_love is None @@ -72,102 +60,17 @@ def test_invalid_render_mode_rejected(self): Config(render="invalid") -class TestConfigFromArgs: - """Tests for Config.from_args() method.""" - - def test_cli_args_used(self, clean_env): - """CLI arguments are used when provided.""" - args = Namespace( - host="0.0.0.0", - port=9999, - render="headless", - debug=None, - settings=None, - path_balatro=None, - path_lovely=None, - path_love=None, - platform=None, - path_logs=None, - ) - config = Config.from_args(args) - - assert config.host == "0.0.0.0" - assert config.port == 9999 - assert config.render == "headless" - - def test_cli_overrides_env(self, clean_env, monkeypatch): - """CLI args override environment variables.""" - monkeypatch.setenv("BALATROBOT_PORT", "8888") - - args = Namespace( - host=None, - port=9999, - render=None, - debug=None, - settings=None, - path_balatro=None, - path_lovely=None, - path_love=None, - platform=None, - path_logs=None, - ) - config = Config.from_args(args) - - assert config.port == 9999 # CLI wins over env - - def test_env_fallback(self, clean_env, monkeypatch): - """Environment variables used when CLI args are None.""" - monkeypatch.setenv("BALATROBOT_PORT", "8888") - monkeypatch.setenv("BALATROBOT_DEBUG", "1") - - args = Namespace( - host=None, - port=None, - render=None, - debug=None, - settings=None, - path_balatro=None, - path_lovely=None, - path_love=None, - platform=None, - path_logs=None, - ) - config = Config.from_args(args) - - assert config.port == 8888 - assert config.debug is True - - def test_settings_from_cli(self, clean_env): - """Settings profile name from CLI args.""" - args = Namespace( - host=None, - port=None, - render=None, - debug=None, - settings="fast", - path_balatro=None, - path_lovely=None, - path_love=None, - platform=None, - path_logs=None, - ) - config = Config.from_args(args) - - assert config.settings == "fast" - - class TestConfigFromEnv: """Tests for Config.from_env() method.""" def test_loads_env_vars(self, clean_env, monkeypatch): """Loads configuration from environment variables.""" - monkeypatch.setenv("BALATROBOT_PORT", "9999") monkeypatch.setenv("BALATROBOT_HOST", "0.0.0.0") monkeypatch.setenv("BALATROBOT_DEBUG", "1") config = Config.from_env() - assert config.port == 9999 + assert config.port == 12346 assert config.host == "0.0.0.0" assert config.debug is True diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index ec364143..228e38bb 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -62,6 +62,7 @@ def test_serve_help(self): assert "--path-lovely" in result.output assert "--path-love" in result.output assert "--path-logs" in result.output + assert "--host" in result.output # Old flags should NOT be present assert "--fast" not in result.output assert "--headless" not in result.output @@ -132,7 +133,7 @@ def test_config_from_kwargs_explicit_overrides_env(self, clean_env, monkeypatch) monkeypatch.setenv("BALATROBOT_HOST", "env-host") - config = Config.from_kwargs(host="cli-host", port=None) + config = Config.from_kwargs(host="cli-host") assert config.host == "cli-host" def test_config_from_kwargs_falls_back_to_env(self, clean_env, monkeypatch): @@ -141,20 +142,17 @@ def test_config_from_kwargs_falls_back_to_env(self, clean_env, monkeypatch): monkeypatch.setenv("BALATROBOT_HOST", "env-host") - config = Config.from_kwargs(host=None, port=9999) + config = Config.from_kwargs(host=None) assert config.host == "env-host" - assert config.port == 9999 def test_config_from_kwargs_env_var_fallback(self, clean_env, monkeypatch): """Env vars used when options not provided.""" from balatrobot.config import Config monkeypatch.setenv("BALATROBOT_DEBUG", "1") - monkeypatch.setenv("BALATROBOT_PORT", "8888") - config = Config.from_kwargs(debug=None, port=None) + config = Config.from_kwargs(debug=None) assert config.debug is True - assert config.port == 8888 class TestMainApp: From 54cf911e15396f4ca0bd8b4010ec0c866253c65c Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 20:40:29 +0200 Subject: [PATCH 086/121] chore: fix ADR tree formatting and update deprecated env var in test Fix indentation in the settings profiles directory tree within the ADR so the cross symbols align properly. Update the screenshot test to check BALATROBOT_RENDER instead of the deprecated BALATROBOT_HEADLESS environment variable. --- docs/adr/0001-settings-redesign.md | 12 ++++++------ tests/lua/endpoints/test_screenshot.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/adr/0001-settings-redesign.md b/docs/adr/0001-settings-redesign.md index 64550622..ccaa2c3b 100644 --- a/docs/adr/0001-settings-redesign.md +++ b/docs/adr/0001-settings-redesign.md @@ -19,14 +19,14 @@ BalatroBot uses bundled **settings profiles** to configure Balatro's game settin ``` src/lua/profiles/ ├── default/ -│ ├── settings.lua # required — merged into G.SETTINGS -│ └── profile.lua # optional — merged into G.PROFILES[n] -└── fast/ - ├── settings.lua - └── profile.lua +│ ├── settings.lua +│ └── profile.lua +├── fast/ +│ ├── settings.lua +│ └── profile.lua ├── turbo/ │ ├── settings.lua - └── profile.lua +│ └── profile.lua └── light/ ├── settings.lua └── profile.lua diff --git a/tests/lua/endpoints/test_screenshot.py b/tests/lua/endpoints/test_screenshot.py index be71e127..0200a776 100644 --- a/tests/lua/endpoints/test_screenshot.py +++ b/tests/lua/endpoints/test_screenshot.py @@ -14,7 +14,7 @@ load_fixture, ) -HEADLESS = os.getenv("BALATROBOT_HEADLESS") == "1" +HEADLESS = os.getenv("BALATROBOT_RENDER") == "headless" @pytest.mark.skipif( From d98a40ec7ea5abb1dadf771a533779f1849d9c06 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 20:59:49 +0200 Subject: [PATCH 087/121] fix(mod): validate render mode in Lua and abort on invalid values Previously, an unrecognized BALATROBOT_RENDER value (e.g. "garbage") was silently ignored and fell through to headfull behaviour with no warning. Add an explicit headfull branch and an else clause that sends an error message and prevents the mod from activating (returns false). This closes a validation gap: Python catches invalid render modes at the CLI layer via Config.__post_init__, but environments set directly via env vars (bypassing the CLI) were unchecked on the Lua side. --- src/lua/settings.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 5f64ab58..0959c161 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -167,10 +167,18 @@ BB_SETTINGS.setup = function() apply_profile(BB_SETTINGS.settings) -- Render mode - if BB_SETTINGS.render == "headless" then + if BB_SETTINGS.render == "headfull" then + -- default, no special configuration needed + elseif BB_SETTINGS.render == "headless" then configure_headless() elseif BB_SETTINGS.render == "ondemand" then configure_ondemand() + else + sendErrorMessage( + "Invalid render mode '" .. BB_SETTINGS.render .. "'. Must be headfull, headless, or ondemand. Aborting.", + "BB.SETTINGS" + ) + return false end return true From c689d43e835246818b7ea4a397f247a2fd13af02 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 20:59:56 +0200 Subject: [PATCH 088/121] refactor(config): remove BOOL_FIELDS frozenset, inline field check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With only a single bool field (debug) remaining after the settings profile migration, the BOOL_FIELDS frozenset and generic dispatch in _parse_env_value added indirection without benefit. Replace with a direct field == "debug" check in both the parser and to_env(). Also document that settings_callback's regex validation is a courtesy guard — the authoritative check lives in apply_profile() on the Lua side — so readers know why the same regex appears in two places. --- src/balatrobot/cli/serve.py | 6 +++++- src/balatrobot/config.py | 8 +++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 78263e89..7716c094 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -23,7 +23,11 @@ def settings_callback(value: str | None) -> str | None: - """Validate --settings as a bare profile name.""" + """Validate --settings as a bare profile name. + + This is a courtesy guard for CLI users. The same regex is enforced + on the Lua side in apply_profile() — that validation is authoritative. + """ if value is None: return None if not _SETTINGS_RE.match(value): diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index e63c7e24..797b5c66 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -16,14 +16,12 @@ "path_logs": "BALATROBOT_PATH_LOGS", } -BOOL_FIELDS = frozenset({"debug"}) - RENDER_CHOICES = frozenset({"headfull", "headless", "ondemand"}) def _parse_env_value(field: str, value: str) -> str | bool: - """Convert env var string to proper type.""" - if field in BOOL_FIELDS: + """Coerce env var string to the right Python type.""" + if field == "debug": return value in ("1", "true") return value @@ -90,7 +88,7 @@ def to_env(self) -> dict[str, str]: value = getattr(self, field) if value is None: continue - if field in BOOL_FIELDS: + if field == "debug": if value: env[env_var] = "1" else: From 73138e850587a2c8b5d82e49322b47a68c0284ec Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 21:28:52 +0200 Subject: [PATCH 089/121] feat(profiles): tune settings for bot operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the forced "en-us" language from all four profiles so Balatro defaults to the system locale instead. This makes UI text behave naturally when a human watches headfull runs. On the light and turbo profiles, also disable bloom and enable vsync — these are headless-optimised profiles where every dropped frame is wasted work, so aggressive power-saving makes sense. --- src/lua/profiles/default/settings.lua | 1 - src/lua/profiles/fast/settings.lua | 1 - src/lua/profiles/light/settings.lua | 5 ++--- src/lua/profiles/turbo/settings.lua | 5 ++--- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/lua/profiles/default/settings.lua b/src/lua/profiles/default/settings.lua index 2505fc98..a6be1591 100644 --- a/src/lua/profiles/default/settings.lua +++ b/src/lua/profiles/default/settings.lua @@ -21,5 +21,4 @@ return { ["tutorial_complete"] = true, ["current_setup"] = "New Run", ["crashreports"] = false, - ["language"] = "en-us", } diff --git a/src/lua/profiles/fast/settings.lua b/src/lua/profiles/fast/settings.lua index 4403c4a5..5f835554 100644 --- a/src/lua/profiles/fast/settings.lua +++ b/src/lua/profiles/fast/settings.lua @@ -21,5 +21,4 @@ return { ["tutorial_complete"] = true, ["current_setup"] = "New Run", ["crashreports"] = false, - ["language"] = "en-us", } diff --git a/src/lua/profiles/light/settings.lua b/src/lua/profiles/light/settings.lua index ce9ea414..09bf17b0 100644 --- a/src/lua/profiles/light/settings.lua +++ b/src/lua/profiles/light/settings.lua @@ -9,11 +9,11 @@ return { ["shadows"] = "Off", ["texture_scaling"] = 1, ["crt"] = 0, - ["bloom"] = 1, + ["bloom"] = 0, }, ["WINDOW"] = { ["screenmode"] = "Windowed", - ["vsync"] = 0, + ["vsync"] = 1, }, ["screenshake"] = 0, ["reduced_motion"] = true, @@ -21,5 +21,4 @@ return { ["tutorial_complete"] = true, ["current_setup"] = "New Run", ["crashreports"] = false, - ["language"] = "en-us", } diff --git a/src/lua/profiles/turbo/settings.lua b/src/lua/profiles/turbo/settings.lua index 0af087d4..3803c0fe 100644 --- a/src/lua/profiles/turbo/settings.lua +++ b/src/lua/profiles/turbo/settings.lua @@ -9,11 +9,11 @@ return { ["shadows"] = "Off", ["texture_scaling"] = 1, ["crt"] = 0, - ["bloom"] = 1, + ["bloom"] = 0, }, ["WINDOW"] = { ["screenmode"] = "Windowed", - ["vsync"] = 0, + ["vsync"] = 1, }, ["screenshake"] = 0, ["reduced_motion"] = true, @@ -21,5 +21,4 @@ return { ["tutorial_complete"] = true, ["current_setup"] = "New Run", ["crashreports"] = false, - ["language"] = "en-us", } From 004d27f60cac43cbca887e91b7ba07db9294dc72 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 21:29:00 +0200 Subject: [PATCH 090/121] feat(settings): force English locale and disable achievements in bot mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set G.F_ENGLISH_ONLY so card names, tooltips, and UI text use deterministic English strings regardless of system locale — critical for parsing game state from API responses. Set G.F_NO_ACHIEVEMENTS to prevent pop-up dialogs that could stall a headless or on-demand run. --- src/lua/settings.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 0959c161..54ed3480 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -159,6 +159,8 @@ BB_SETTINGS.setup = function() -- Hardcoded overrides for bot operation G.F_SKIP_TUTORIAL = true + G.F_ENGLISH_ONLY = true + G.F_NO_ACHIEVEMENTS = true G.F_VERBOSE = BB_SETTINGS.debug G.PROFILES[profile_num].all_unlocked = true From 23f5c6b50d75375ec8d4e957395497e07d6feed3 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 21:51:38 +0200 Subject: [PATCH 091/121] fix(lua): widen types, localize setup, clean up gamestate - Change state_value param type from integer to integer|string in dispatcher's get_state_name, matching actual usage. - Add req_file and res_file fields to the Server type annotation for JSON-RPC request/response logging. - Make setup() a local function defined before BB_SETTINGS table so it can reference the table directly rather than via a function field on a partially-constructed global. - Split chained gsub in strip_color_codes into a local variable for readability. --- src/lua/core/dispatcher.lua | 2 +- src/lua/settings.lua | 29 +++++++++++++++-------------- src/lua/utils/gamestate.lua | 3 ++- src/lua/utils/types.lua | 2 ++ 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index a73f49cc..845cb875 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -13,7 +13,7 @@ local socket = require("socket") ---@type table<integer, string>? local STATE_NAME_CACHE = nil ----@param state_value integer +---@param state_value integer|string ---@return string local function get_state_name(state_value) if not STATE_NAME_CACHE then diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 54ed3480..d91f71b6 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -1,5 +1,5 @@ --[[ -BalatroBot v2 settings — profile-based configuration. +BalatroBot settings — profile-based configuration. Environment variables read by the Lua mod: BALATROBOT_HOST - Server hostname (default: 127.0.0.1) @@ -11,18 +11,6 @@ Environment variables read by the Lua mod: ---@diagnostic disable: duplicate-set-field ----@type Settings -BB_SETTINGS = { - host = os.getenv("BALATROBOT_HOST") or "127.0.0.1", - port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, - render = os.getenv("BALATROBOT_RENDER") or "headfull", - debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, - settings = os.getenv("BALATROBOT_SETTINGS"), -} - ----@type boolean? -BB_RENDER = nil - --- Deep merge source into target table (recursive) ---@param target table ---@param source table @@ -145,7 +133,7 @@ end --- Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. ---@return boolean -BB_SETTINGS.setup = function() +local function setup() -- Gate: only activate when in-game profile is named "BalatroBot" local profile_num = G.SETTINGS.profile or 1 local profile = G.PROFILES[profile_num] @@ -185,3 +173,16 @@ BB_SETTINGS.setup = function() return true end + +---@type Settings +BB_SETTINGS = { + host = os.getenv("BALATROBOT_HOST") or "127.0.0.1", + port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, + render = os.getenv("BALATROBOT_RENDER") or "headfull", + debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, + settings = os.getenv("BALATROBOT_SETTINGS"), + setup = setup, +} + +---@type boolean? +BB_RENDER = nil diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 90aa3d66..e31c5453 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -524,7 +524,8 @@ local function strip_color_codes(text) return "" end -- Remove color codes: {C:color_name}, {X:mult}, etc. and closing {} - return text:gsub("%b{}", ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + local result = text:gsub("%b{}", ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + return result end ---Gets voucher effect description using the game's localize function diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index ecf147a5..bda487af 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -292,6 +292,8 @@ ---@field current_request_id integer|string|nil Current JSON-RPC 2.0 request ID being processed (nil if no active request) ---@field client_state table? HTTP request parsing state for current client (buffer, headers, etc.) (nil if no client connected) ---@field openrpc_spec string? OpenRPC specification JSON string (loaded at init, nil before init) +---@field req_file file*? File handle for recording JSON-RPC request bodies (nil if logging disabled) +---@field res_file file*? File handle for recording JSON-RPC response bodies (nil if logging disabled) ---@field init? fun(): boolean Initialize HTTP server socket and load OpenRPC spec ---@field accept? fun(): boolean Accept new HTTP client connection ---@field send_response? fun(response: Response.Endpoint): boolean Send JSON-RPC 2.0 response over HTTP to client From 89945c49a0010628f2399cfa15dbb1c2f1be3e14 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 21:51:41 +0200 Subject: [PATCH 092/121] fix(makefile): correct lua-language-server --check flag Replace `--check balatrobot.lua src/lua` with `--check="$(CURDIR)"` so the language server checks the entire project root as intended, not just two specific paths. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0d54dd2f..ecd1a239 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ typecheck: ## Run type checkers (Python and Lua) @ty check @if command -v lua-language-server >/dev/null 2>&1 && [ -f .luarc.json ]; then \ $(PRINT) "$(YELLOW)Running Lua type checker...$(RESET)"; \ - lua-language-server --check balatrobot.lua src/lua \ + lua-language-server --check="$(CURDIR)" \ --configpath="$(CURDIR)/.luarc.json" 2>/dev/null; \ else \ $(PRINT) "$(BLUE)Skipping Lua type checker (lua-language-server not found or .luarc.json missing)$(RESET)"; \ From 60d5d1fe2b3372978bfe21e253e5fea68c501f87 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 21:55:56 +0200 Subject: [PATCH 093/121] chore(lua): remove stale undefined-global diagnostic suppressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These `---@diagnostic disable-line: undefined-global` annotations were leftover from early development when the Lua type checker flagged Balatro globals (G, localize, get_blind_amount, etc.) as unknown. Now that the type environment is properly configured, these suppressions are noise — remove them. --- src/lua/endpoints/load.lua | 2 +- src/lua/endpoints/save.lua | 4 ++-- src/lua/utils/gamestate.lua | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 7f532550..f2951056 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -72,7 +72,7 @@ return { -- Load using game's built-in functions G:delete_run() - G.SAVED_GAME = get_compressed(temp_filename) ---@diagnostic disable-line: undefined-global + G.SAVED_GAME = get_compressed(temp_filename) if G.SAVED_GAME == nil then send_response({ diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 7c6ef004..e7f5af20 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -67,10 +67,10 @@ return { -- Call save_run() and use compress_and_save sendInfoMessage("Saving to " .. path, "BB.ENDPOINTS") - save_run() ---@diagnostic disable-line: undefined-global + save_run() local temp_filename = "balatrobot_temp_save_" .. BB_SETTINGS.port .. ".jkr" - compress_and_save(temp_filename, G.ARGS.save_run) ---@diagnostic disable-line: undefined-global + compress_and_save(temp_filename, G.ARGS.save_run) -- Read from temp and write to target path using nativefs local save_dir = love.filesystem.getSaveDirectory() diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index e31c5453..b6d03382 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -490,11 +490,11 @@ local function get_blind_effect_from_ui(blind_config) -- Access localization data directly (more reliable than using localize function) -- Path: G.localization.descriptions.Blind[blind_key].text - if not G or not G.localization then ---@diagnostic disable-line: undefined-global + if not G or not G.localization then return "" end - local loc_data = G.localization.descriptions ---@diagnostic disable-line: undefined-global + local loc_data = G.localization.descriptions if not loc_data or not loc_data.Blind or not loc_data.Blind[blind_config.key] then return "" end @@ -578,11 +578,11 @@ local function get_voucher_effect(voucher_key) end -- Use localize to get description text - if not localize then ---@diagnostic disable-line: undefined-global + if not localize then return "" end - local text_lines = localize({ ---@diagnostic disable-line: undefined-global + local text_lines = localize({ type = "raw_descriptions", key = voucher_key, set = "Voucher", @@ -608,7 +608,7 @@ local function get_tag_info(tag_key) return result end - if not localize then ---@diagnostic disable-line: undefined-global + if not localize then return result end @@ -645,7 +645,7 @@ local function get_tag_info(tag_key) end -- Use localize with raw_descriptions type (matches Balatro's internal approach) - local text_lines = localize({ type = "raw_descriptions", key = tag_key, set = "Tag", vars = loc_vars }) ---@diagnostic disable-line: undefined-global + local text_lines = localize({ type = "raw_descriptions", key = tag_key, set = "Tag", vars = loc_vars }) if text_lines and type(text_lines) == "table" then result.effect = table.concat(text_lines, " ") end @@ -732,7 +732,7 @@ function gamestate.get_blinds_info() -- Get base blind amount for current ante local ante = G.GAME.round_resets.ante or 1 - local base_amount = get_blind_amount(ante) ---@diagnostic disable-line: undefined-global + local base_amount = get_blind_amount(ante) -- Apply ante scaling with null check local ante_scaling = (G.GAME.starting_params and G.GAME.starting_params.ante_scaling) or 1 From 75a5f55ca483088798812fb43e3be2df76f61351 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 10 Jun 2026 22:03:07 +0200 Subject: [PATCH 094/121] docs(contributing): update .luarc.json docs for gitignored setup The .luarc.json is now gitignored (not committed), so update the contributing guide to reflect that developers must copy the example config locally instead of editing a committed file. Also document that SMODS scanning .json mod directories produces a harmless log error, and add the Balatro globals (localize, get_blind_amount, get_compressed, save_run, compress_and_save) that were recently removed from inline diagnostic suppressions. --- docs/contributing.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 473efa20..8295821f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -43,15 +43,21 @@ export BALATROBOT_PATH_LOVELY='/path/to/liblovely.dylib' ### Lua LSP Configuration -The `.luarc.json` file should be placed at the root of the balatrobot repository. It configures the Lua Language Server for IDE support (autocomplete, diagnostics, type checking). +The `.luarc.json` file at the project root configures the Lua Language Server for IDE support (autocomplete, diagnostics, type checking). -!!! info "Update Library Paths" +!!! info "Gitignored — copy from template" - You **must** update the `workspace.library` paths in `.luarc.json` to match your system: + `.luarc.json` is **gitignored** and not committed to the repo. Copy the example below to create your own, or check the current version in the CI logs / ask a maintainer. - - Steamodded LSP definitions: `path/to/Mods/smods/lsp_def` - - Love2D library: `path/to/love2d/library` (clone locally: [LuaCATS/love2d](https://github.com/LuaCATS/love2d)) - - LuaSocket library: `path/to/luasocket/library` (clone locally: [LuaCATS/luasocket](https://github.com/LuaCATS/luasocket)) + SMODS scans all `.json` files in mod directories and will log a harmless error for `.luarc.json` — this is expected and does not affect mod loading. + +!!! info "Library Paths" + + You **must** update the `workspace.library` paths to match your system: + + - **Steamodded LSP definitions:** `/path/to/Balatro/Mods/smods/lsp_def`. + - **Love2D library:** `/path/to/love2d/library` (clone locally: [LuaCATS/love2d](https://github.com/LuaCATS/love2d)) + - **LuaSocket library:** `/path/to/luasocket/library` (clone locally: [LuaCATS/luasocket](https://github.com/LuaCATS/luasocket)) **Example `.luarc.json`:** @@ -74,7 +80,12 @@ The `.luarc.json` file should be placed at the root of the balatrobot repository "G", "BB_GAMESTATE", "BB_ERROR_NAMES", - "BB_ENDPOINTS" + "BB_ENDPOINTS", + "localize", + "get_blind_amount", + "get_compressed", + "save_run", + "compress_and_save" ] }, "type": { From 12fcf3dfad171f86d793f74d4296ae44f8c52bd5 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Thu, 11 Jun 2026 08:49:00 +0200 Subject: [PATCH 095/121] docs(skill): update balatrobot skill for turbo profile and clarify usage - Switch recommended settings profile from "fast" to "turbo" - Remove env var mapping reference (duplicated in config.py) - Remove BalatroBot profile requirement note (outdated) - Add quotes around glob patterns in --requests examples Reorder API docs to surface method lookup first --- .agents/skills/balatrobot/SKILL.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index 595337ce..c17fb4be 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -16,20 +16,16 @@ balatrobot serve --help Typical invocation: ```bash -balatrobot serve --render headless --settings fast --debug +balatrobot serve --render headless --settings turbo --debug ``` Key flags: - `--render [headfull|headless|ondemand]` — rendering mode (default: headfull) - `--settings NAME` — settings profile name (default: "default") -- `--debug` — enable debug endpoints +- `--debug` — enable debug endpoints (during divergence we need to turn this on) - `--num` — number of instances -- `--path-*` — path overrides (`--path-balatro`, `--path-lovely`, `--path-love`, `--path-logs`) - -All flags have `BALATROBOT_*` env var equivalents (e.g. `BALATROBOT_RENDER=headless`). See `src/balatrobot/config.py` for the full mapping. - -**Requirement:** The mod only activates when the selected Balatro in-game profile is named exactly `BalatroBot`. Create this profile in Balatro's profile selector and select it before launching via `serve`. +- `--path-*` — path overrides (don't need to use these) `serve` auto-allocates ports, prints instance URLs and the session logs directory, then blocks until Ctrl+C. It writes a state file so other commands can discover the running instances. @@ -57,7 +53,7 @@ balatrobot api <method> [JSON_PARAMS] balatrobot api <method> --help ``` -Auto-discovers the running instance from the state file — no `--host`/`--port` needed for single-instance sessions. For multi-instance pools, use `-i`/`--index` (0-based, default 0). +**Important**: to use the right `<method>` and `[JSON_PARAMS]` you must read `docs/api.md` which contains the full API reference (methods, errors, states). Params are a JSON string (default `{}`). Examples: @@ -78,8 +74,8 @@ balatrobot api gamestate | jq '.state' balatrobot api gamestate | jq '{state, money, hand: .hand.count}' ``` -API errors surface as `<NAME> - <message>` on stderr (e.g. `INVALID_STATE`, `BAD_REQUEST`). -Full API reference (methods, errors, states): `docs/api.md`. +`balatrobot api` auto-discovers the running instance from the state file — no `--host`/`--port` needed for single-instance sessions. +For multi-instance pools, use `-i`/`--index` (0-based, default 0). ## Logs @@ -88,8 +84,8 @@ Each session directory (`logs/<timestamp>/`) contains per-instance files: `<port ## `api --requests` — replay & verify ```bash -balatrobot api --requests logs/<ts>/<port>.req.jsonl -balatrobot api --requests logs/<ts>/<port>.req.jsonl --responses logs/<ts>/<port>.res.jsonl +balatrobot api --requests "logs/<ts>/<port>.req.jsonl" +balatrobot api --requests "logs/<ts>/<port>.req.jsonl" --responses "logs/<ts>/<port>.res.jsonl" ``` Replays a JSONL request trace against a running instance. `--responses` compares each live response against the recorded one (exits on first divergence). Mutually exclusive with positional `METHOD`. From 38fd4e4352d7fcd20a489e8c0e07df89f84236f7 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Thu, 11 Jun 2026 19:42:13 +0200 Subject: [PATCH 096/121] docs(skill): fix double space in balatrobot skill Remove extra whitespace between "read" and the filename in the API method reference paragraph. --- .agents/skills/balatrobot/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index c17fb4be..a9f5c77e 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -53,7 +53,7 @@ balatrobot api <method> [JSON_PARAMS] balatrobot api <method> --help ``` -**Important**: to use the right `<method>` and `[JSON_PARAMS]` you must read `docs/api.md` which contains the full API reference (methods, errors, states). +**Important**: to use the right `<method>` and `[JSON_PARAMS]` you must read `docs/api.md` which contains the full API reference (methods, errors, states). Params are a JSON string (default `{}`). Examples: From 0f0f54dbda5bfdbff736c4184b3dc0088c37bf6f Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Thu, 11 Jun 2026 19:42:21 +0200 Subject: [PATCH 097/121] test(lua): add type annotation to test_concurrent_requests param Annotate the `instance` fixture parameter as `InstanceInfo` for consistency with other tests in the same file. --- tests/lua/core/test_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index 1c4f309d..d80d3359 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -506,7 +506,7 @@ class TestHTTPServerConcurrency: """Tests for concurrent request handling.""" def test_concurrent_requests_do_not_crash( - self, instance, balatro_server, client: httpx.Client + self, instance: InstanceInfo, balatro_server, client: httpx.Client ) -> None: """Two concurrent requests must not crash the server (#193).""" barrier = threading.Barrier(2) From 687731a6151c487d932c70cef0fb39996ccbadfa Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Thu, 11 Jun 2026 22:01:00 +0200 Subject: [PATCH 098/121] feat(skill): add triage-issue skill for reproducing GitHub issues Automates the full triage workflow: fetch issue data from GitHub, download attachments, create a branch, reproduce the bug, and generate an HTML investigation report. Includes a Tokyo Night themed report template with verdict badges. --- .agents/skills/triage-issue/REFERENCE.md | 61 +++++++++++++++++++ .agents/skills/triage-issue/SKILL.md | 77 ++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 .agents/skills/triage-issue/REFERENCE.md create mode 100644 .agents/skills/triage-issue/SKILL.md diff --git a/.agents/skills/triage-issue/REFERENCE.md b/.agents/skills/triage-issue/REFERENCE.md new file mode 100644 index 00000000..f66054bf --- /dev/null +++ b/.agents/skills/triage-issue/REFERENCE.md @@ -0,0 +1,61 @@ +# HTML Report Reference + +## Palette (Tokyo Night) + +Include this `<style>` block in the report. It provides a dark theme with semantic color variables. Compose the rest of the report freely — choose whatever sections, layout, and HTML elements best explain the issue. + +```css +:root { + --bg: #1a1b26; --surface: #24283b; --border: #3b4261; + --text: #c0caf5; --muted: #565f89; --accent: #7aa2f7; + --green: #9ece6a; --red: #f7768e; --yellow: #e0af68; --purple: #bb9af7; +} +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); color: var(--text); line-height: 1.65; + padding: 2.5rem; max-width: 860px; margin: 0 auto; font-size: 0.9rem; +} +h2 { color: var(--accent); margin: 2rem 0 0.5rem; } +code { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 0.1rem 0.35rem; } +pre { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; overflow-x: auto; font-size: 0.82rem; } +a { color: var(--accent); } +hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; } +``` + +## Verdict badges + +```css +.badge { display: inline-block; padding: 0.2rem 0.65rem; border-radius: 20px; font-size: 0.78rem; font-weight: 600; } +.badge-green { background: rgba(158,206,106,0.15); color: var(--green); border: 1px solid rgba(158,206,106,0.3); } +.badge-red { background: rgba(247,118,142,0.15); color: var(--red); border: 1px solid rgba(247,118,142,0.3); } +.badge-yellow { background: rgba(224,175,104,0.15); color: var(--yellow); border: 1px solid rgba(224,175,104,0.3); } +``` + +| Verdict | Class | Color | +|---------|-------|-------| +| Reproduced / bug confirmed | `badge-red` | `--red` | +| Already fixed / no action needed | `badge-green` | `--green` | +| Needs manual review / inconclusive | `badge-yellow` | `--yellow` | + +## Required header + +Every report must start with: + +```html +<h1>{issue title}</h1> +<p class="meta"> + <code>{scope}</code> · + <a href="https://github.com/coder/balatrobot/issues/{NNN}">#{NNN}</a> · + coder/balatrobot · + <span class="badge badge-{color}">{VERDICT}</span> +</p> +``` + +The link to the original issue on GitHub is mandatory. + +## Guidelines + +- **Compose freely.** The LLM decides which sections (`<h2>`) to include based on what best explains the issue. There is no fixed template beyond the header. +- **Use the palette variables** for all colors. Use `--red`/`--green` for diff highlights, `--accent` for headings and links, `--muted` for secondary text. +- **Keep it simple.** Plain HTML. No frameworks, no JavaScript. The report is a static file opened in a browser. +- **Save to** `/tmp/balatrobot/issues/{NNN}/report.html` diff --git a/.agents/skills/triage-issue/SKILL.md b/.agents/skills/triage-issue/SKILL.md new file mode 100644 index 00000000..1b062251 --- /dev/null +++ b/.agents/skills/triage-issue/SKILL.md @@ -0,0 +1,77 @@ +--- +name: triage-issue +description: Triage and reproduce GitHub issues for the coder/balatrobot repo. Fetches issue data, downloads attachments, creates a branch, reproduces the bug, and generates an HTML investigation report. Use when the user says "/triage-issue NNN" or asks to reproduce a specific issue by number. +--- + +# Triage Issue + +Reproduce and investigate a GitHub issue end-to-end, producing an HTML report. + +## Quick start + +User provides a GitHub issue URL. Extract the issue number and run the full workflow below. + +## Workflow + +### 1. Fetch & download + +```bash +mkdir -p /tmp/balatrobot/issues/NNN +gh issue view NNN --repo coder/balatrobot --json title,body,comments,labels,state \ + | tee /tmp/balatrobot/issues/NNN/issue.json +``` + +Extract attachment URLs from body + comments and download: +```bash +gh issue view NNN --repo coder/balatrobot --json body,comments -q '.body, (.comments[].body)' \ + | grep -oE 'https://github.com/user-attachments/files/[^ )]+' \ + | xargs -I{} curl -sLo /tmp/balatrobot/issues/NNN/$(basename {}) '{}' +``` + +### 2. Read before executing + +**Before running anything**, read all downloaded files: +- **`script.py`** — reproduction script. Read fully before running. +- **`*.req.jsonl`** — replayable via `balatrobot api --requests`. +- **`*.res.jsonl`** — response log for comparison. +- **`*.log`** — Balatro log output for error context. + +Choose reproduction method: `script.py` if present (adapt port), else replay `.req.jsonl`. + +### 3. Create branch + +Derive prefix from issue title (`fix(...)` → `fix/`, `feat(...)` → `feat/`, etc., fallback `fix/`): +```bash +git checkout <current-active-branch> +git checkout -b <prefix>/issue-NNN +``` + +### 4. Reproduce + +```bash +balatrobot serve --render headless --settings turbo --debug +``` + +Wait for ready, then reproduce using the chosen method. Run **≥3 times** to confirm consistency. + +### 5. Report & cleanup + +Generate HTML report at `/tmp/balatrobot/issues/NNN/report.html`. See [REFERENCE.md](REFERENCE.md) for template. Must include: issue title, verdict badge (`REPRODUCED`/`ALREADY FIXED`/`NEEDS MANUAL REVIEW`), summary, reproduction steps, results table, analysis, conclusion. + +Then: +- `balatrobot stop` +- If already fixed → delete branch, switch back. +- If reproducible → keep branch. +- `open /tmp/balatrobot/issues/NNN/report.html` + +## Checklist + +Before reporting done, verify: +- [ ] Issue data fetched and saved to `/tmp/balatrobot/issues/NNN/` +- [ ] All attachments downloaded +- [ ] Attachments read and understood before execution +- [ ] Branch created from active branch +- [ ] Issue reproduced (or confirmed already fixed) with ≥3 runs +- [ ] HTML report generated at `/tmp/balatrobot/issues/NNN/report.html` +- [ ] Report opened in browser +- [ ] Server stopped, branch cleaned up if no fix needed From ee22cc1547307becf02f34633d73b94b11e42a5d Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Thu, 11 Jun 2026 22:02:24 +0200 Subject: [PATCH 099/121] test(lua): add regression test for selling Invisible Joker Selling an Invisible Joker at 2/2 charges causes the sell endpoint to hang indefinitely because its on-sell ability duplicates a random joker, keeping the card count unchanged. This test reproduces the exact scenario from issue #195 and currently fails (red phase). --- tests/fixtures/fixtures.json | 74 ++++++++++++++++++++++++++++++++ tests/lua/endpoints/test_sell.py | 14 ++++++ 2 files changed, 88 insertions(+) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 58e75437..cfb3cfc8 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -2208,6 +2208,80 @@ "method": "select", "params": {} } + ], + "state-SHOP--jokers.count-2--jokers.cards[0].key-j_invisible": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "RED", + "stake": "WHITE", + "seed": "INVIS3" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "add", + "params": { + "key": "j_invisible" + } + }, + { + "method": "set", + "params": { + "chips": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "next_round", + "params": {} + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "add", + "params": { + "key": "j_greedy_joker" + } + } ] }, "add": { diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py index 9090ae91..f8f1e789 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -139,6 +139,20 @@ def test_sell_consumable_in_SHOP(self, client: httpx.Client) -> None: assert after["consumables"]["count"] == 0 assert before["money"] < after["money"] + def test_sell_invisible_joker(self, client: httpx.Client) -> None: + """Selling Invisible Joker should not hang (issue #195).""" + before = load_fixture( + client, + "sell", + "state-SHOP--jokers.count-2--jokers.cards[0].key-j_invisible", + ) + assert before["state"] == "SHOP" + assert before["jokers"]["count"] == 2 + + response = api(client, "sell", {"joker": 0}, timeout=10.0) + after = assert_gamestate_response(response) + assert after["jokers"]["count"] >= 1 + class TestSellEndpointValidation: """Test sell endpoint parameter validation.""" From dfe0f65cb918d0c57cd3e9b935c2ed0fe6ebe3f1 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Thu, 11 Jun 2026 22:02:49 +0200 Subject: [PATCH 100/121] fix(lua.endpoints): use card.removed instead of count check in sell Replace the completion condition in the sell endpoint that checked card_count == initial_count - 1 with card.removed == true. The old check failed when jokers like Invisible Joker modify the card area on sell (e.g. duplicating a neighbor keeps the count unchanged). The card_gone loop is also removed as card.removed makes it redundant -- it is an O(1) property on the specific card instance, immune to any current or future joker side effects. Closes #195 --- src/lua/endpoints/sell.lua | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 5d1a6f7b..6b334038 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -112,11 +112,8 @@ return { local card = source_array[pos] -- Track initial state for completion verification - local area = sell_type == "joker" and G.jokers or G.consumeables - local initial_count = area.config.card_count local initial_money = G.GAME.dollars local expected_money = initial_money + card.sell_cost - local card_id = card.sort_id -- Log what we're selling local item_name = card.ability and card.ability.name or "Unknown" @@ -132,42 +129,28 @@ return { -- Call the game function to trigger sell G.FUNCS.sell_card(mock_element) - -- Wait for sell completion with comprehensive verification + -- Wait for sell completion with verification G.E_MANAGER:add_event(Event({ 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) + -- 1. Card was removed + local card_removed = card.removed == true -- 2. Money increased by sell_cost local money_increased = (G.GAME.dollars == expected_money) - -- 3. Card no longer exists (verify by unique_val) - local card_gone = true - for _, c in ipairs(current_array) do - if c.sort_id == card_id then - card_gone = false - break - 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 or G.STATE == G.STATES.SMODS_BOOSTER_OPENED ) - -- All conditions must be met - if count_decreased and money_increased and card_gone and state_stable and valid_state then + if card_removed and money_increased and state_stable and valid_state then sendDebugMessage("sell() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true From c28696915fba0aa8210f3fea701c9e7eaabeca6a Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 10:08:38 +0200 Subject: [PATCH 101/121] feat(lua): migrate Enhancement enum from ALL-CAPS to m_* keys Replace custom ALL-CAPS enhancement values (BONUS, MULT, etc.) with Balatros in-game registry keys (m_bonus, m_mult, etc.) so the API exposes real G.P_CENTERS keys instead of invented aliases. This is step 1 of the enum migration plan (ENUM_MIGRATION_PLAN.md). Seal, Edition, Deck, and Stake are untouched (later steps). Source changes: - enums.lua: update Card.Modifier.Enhancement alias to m_* values - add.lua: remove ENHANCEMENT_MAP, validate via m_ prefix and G.P_CENTERS lookup instead of table mapping - gamestate.lua: use card.config.center_key instead of fragile ability.effect string parsing - openrpc.json: update Enhancement const values - docs/api.md: update Enhancement enum table Closes #194. Co-authored-by: icebear <icebear0828@users.noreply.github.com> --- docs/api.md | 20 ++++++++++---------- src/lua/endpoints/add.lua | 26 +++++++++----------------- src/lua/utils/enums.lua | 16 ++++++++-------- src/lua/utils/gamestate.lua | 6 +++--- src/lua/utils/openrpc.json | 16 ++++++++-------- tests/lua/endpoints/test_add.py | 15 ++++++++++++--- tests/lua/endpoints/test_gamestate.py | 2 +- tests/lua/endpoints/test_use.py | 6 +++--- 8 files changed, 54 insertions(+), 53 deletions(-) diff --git a/docs/api.md b/docs/api.md index 79cc2bad..763a7f7c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -919,16 +919,16 @@ Represents a Balatro tag that provides bonuses when triggered. ### Card Modifier Enhancement -| Value | Description | -| ------- | ------------------------------------ | -| `BONUS` | +30 Chips when scored | -| `MULT` | +4 Mult when scored | -| `WILD` | Counts as every suit | -| `GLASS` | X2 Mult when scored | -| `STEEL` | X1.5 Mult while held | -| `STONE` | +50 Chips (no rank/suit) | -| `GOLD` | $3 if held at end of round | -| `LUCKY` | 1/5 chance +20 Mult, 1/15 chance $20 | +| Value | Description | +| --------- | ------------------------------------ | +| `m_bonus` | +30 Chips when scored | +| `m_mult` | +4 Mult when scored | +| `m_wild` | Counts as every suit | +| `m_glass` | X2 Mult when scored | +| `m_steel` | X1.5 Mult while held | +| `m_stone` | +50 Chips (no rank/suit) | +| `m_gold` | $3 if held at end of round | +| `m_lucky` | 1/5 chance +20 Mult, 1/15 chance $20 | ### Blind Type diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 5c5fe390..6e35b8a3 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -58,18 +58,6 @@ local EDITION_MAP = { NEGATIVE = "e_negative", } --- Enhancement conversion table -local ENHANCEMENT_MAP = { - BONUS = "m_bonus", - MULT = "m_mult", - WILD = "m_wild", - GLASS = "m_glass", - STEEL = "m_steel", - STONE = "m_stone", - GOLD = "m_gold", - LUCKY = "m_lucky", -} - ---Detect card type based on key prefix or pattern ---@param key string The card key ---@return string|nil card_type The detected card type or nil if invalid @@ -142,7 +130,7 @@ return { enhancement = { type = "string", required = false, - description = "Enhancement type (BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, LUCKY) - only valid for playing cards", + description = "Enhancement key (m_bonus, m_mult, m_wild, m_glass, m_steel, m_stone, m_gold, m_lucky) - only valid for playing cards", }, eternal = { type = "boolean", @@ -301,17 +289,21 @@ return { return end - -- Validate and convert enhancement value + -- Validate enhancement value local enhancement_value = nil if args.enhancement then - enhancement_value = ENHANCEMENT_MAP[args.enhancement] - if not enhancement_value then + if + type(args.enhancement) ~= "string" + or args.enhancement:sub(1, 2) ~= "m_" + or not G.P_CENTERS[args.enhancement] + then send_response({ - message = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", + message = "Expected an m_* enhancement key (e.g. m_bonus, m_mult)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + enhancement_value = args.enhancement end -- Validate eternal parameter is only for jokers diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 88b2275f..1d23a3ef 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -391,14 +391,14 @@ ---| "NEGATIVE" # N/A (Playing cards). +1 Joker slot (Jokers). +1 Consumable slot (Consumables) ---@alias Card.Modifier.Enhancement ----| "BONUS" # Enhanced card gives an additional +30 Chips when scored ----| "MULT" # Enhanced card gives +4 Mult when scored ----| "WILD" # Enhanced card is considered to be every suit simultaneously ----| "GLASS" # Enhanced card gives X2 Mult when scored ----| "STEEL" # Enhanced card gives X1.5 Mult while held in hand ----| "STONE" # Enhanced card's value is set to +50 Chips ----| "GOLD" # Enhanced card gives $3 if held in hand at end of round ----| "LUCKY" # Enhanced card has a 1 in 5 chance to give +20 Mult. Enhanced card has a 1 in 15 chance to give $20 +---| "m_bonus" # Enhanced card gives an additional +30 Chips when scored +---| "m_mult" # Enhanced card gives +4 Mult when scored +---| "m_wild" # Enhanced card is considered to be every suit simultaneously +---| "m_glass" # Enhanced card gives X2 Mult when scored +---| "m_steel" # Enhanced card gives X1.5 Mult while held in hand +---| "m_stone" # Enhanced card's value is set to +50 Chips +---| "m_gold" # Enhanced card gives $3 if held in hand at end of round +---| "m_lucky" # Enhanced card has a 1 in 5 chance to give +20 Mult. Enhanced card has a 1 in 15 chance to give $20 ---@alias Blind.Type ---| "SMALL" # No special effects - can be skipped to receive a Tag diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index b6d03382..05fc5e02 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -237,9 +237,9 @@ 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 - modifier.enhancement = string.upper(card.ability.effect:gsub(" Card", "")) + -- Enhancement (from center_key for enhanced cards) + if card.config and card.config.center_key and card.config.center_key:sub(1, 2) == "m_" then + modifier.enhancement = card.config.center_key end -- Eternal (boolean from ability) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index f311f711..910496ea 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -1603,35 +1603,35 @@ "description": "Card enhancement type", "oneOf": [ { - "const": "BONUS", + "const": "m_bonus", "description": "+30 Chips when scored" }, { - "const": "MULT", + "const": "m_mult", "description": "+4 Mult when scored" }, { - "const": "WILD", + "const": "m_wild", "description": "Counts as every suit simultaneously" }, { - "const": "GLASS", + "const": "m_glass", "description": "X2 Mult when scored" }, { - "const": "STEEL", + "const": "m_steel", "description": "X1.5 Mult while held in hand" }, { - "const": "STONE", + "const": "m_stone", "description": "+50 Chips, no rank or suit" }, { - "const": "GOLD", + "const": "m_gold", "description": "$3 if held in hand at end of round" }, { - "const": "LUCKY", + "const": "m_lucky", "description": "1 in 5 chance +20 Mult, 1 in 15 chance $20" } ] diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index f482c64a..5114a2ba 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -438,7 +438,16 @@ class TestAddEndpointEnhancement: @pytest.mark.parametrize( "enhancement", - ["BONUS", "MULT", "WILD", "GLASS", "STEEL", "STONE", "GOLD", "LUCKY"], + [ + "m_bonus", + "m_mult", + "m_wild", + "m_glass", + "m_steel", + "m_stone", + "m_gold", + "m_lucky", + ], ) def test_add_playing_card_with_enhancement( self, client: httpx.Client, enhancement: str @@ -470,7 +479,7 @@ def test_add_playing_card_invalid_enhancement(self, client: httpx.Client) -> Non assert_error_response( response, "BAD_REQUEST", - "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", + "Expected an m_* enhancement key (e.g. m_bonus, m_mult)", ) @pytest.mark.parametrize( @@ -486,7 +495,7 @@ def test_add_non_playing_card_with_enhancement_fails( "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0--packs.count-0", ) assert gamestate["state"] == "SHOP" - response = api(client, "add", {"key": key, "enhancement": "BONUS"}) + response = api(client, "add", {"key": key, "enhancement": "m_bonus"}) assert_error_response( response, "BAD_REQUEST", diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index ea725d4b..5243fb96 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -579,7 +579,7 @@ def test_card_set_enhanced(self, client: httpx.Client) -> None: """Test enhanced playing cards have ENHANCED set.""" fixture_name = "state-SELECTING_HAND" load_fixture(client, "gamestate", fixture_name) - response = api(client, "add", {"key": "H_A", "enhancement": "BONUS"}) + response = api(client, "add", {"key": "H_A", "enhancement": "m_bonus"}) # Find the enhanced card (last card in hand) cards = response["result"]["hand"]["cards"] card = cards[-1] diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index 997edb96..b5c24329 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -75,7 +75,7 @@ def test_use_magician_with_one_card(self, client: httpx.Client) -> None: assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [0]}) after = assert_gamestate_response(response) - assert after["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" + assert after["hand"]["cards"][0]["modifier"]["enhancement"] == "m_lucky" def test_use_magician_with_two_cards(self, client: httpx.Client) -> None: """Test using The Magician with 2 cards.""" @@ -87,8 +87,8 @@ def test_use_magician_with_two_cards(self, client: httpx.Client) -> None: assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [7, 5]}) after = assert_gamestate_response(response) - assert after["hand"]["cards"][5]["modifier"]["enhancement"] == "LUCKY" - assert after["hand"]["cards"][7]["modifier"]["enhancement"] == "LUCKY" + assert after["hand"]["cards"][5]["modifier"]["enhancement"] == "m_lucky" + assert after["hand"]["cards"][7]["modifier"]["enhancement"] == "m_lucky" def test_use_familiar_all_hand(self, client: httpx.Client) -> None: """Test using Familiar (destroys cards, #G.hand.cards > 1).""" From 4f7a451fcef9fbf883ac6cb93276cf3f2c37b863 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 10:29:13 +0200 Subject: [PATCH 102/121] feat(lua): migrate Edition enum from ALL-CAPS to e_* keys Replace custom ALL-CAPS edition values (HOLO, FOIL, POLYCHROME, NEGATIVE) with Balatros in-game registry keys (e_holo, e_foil, e_polychrome, e_negative) so the API exposes real G.P_CENTERS keys instead of invented aliases. This is step 2 of the enum migration plan (ENUM_MIGRATION_PLAN.md). Enhancement was migrated in step 1. Seal, Deck, and Stake are untouched (later steps). Source changes: - enums.lua: update Card.Modifier.Edition alias to e_* values - add.lua: remove EDITION_MAP, validate via e_ prefix check instead of table mapping - gamestate.lua: use card.edition.key instead of string.upper(card.edition.type) - openrpc.json: update Edition const values and param description - types.lua: update annotation comment - docs/api.md: update Edition enum table and curl example --- docs/api.md | 16 ++++++++-------- src/lua/endpoints/add.lua | 24 ++++++++---------------- src/lua/utils/enums.lua | 8 ++++---- src/lua/utils/gamestate.lua | 6 +++--- src/lua/utils/openrpc.json | 10 +++++----- src/lua/utils/types.lua | 2 +- tests/lua/endpoints/test_add.py | 26 +++++++++++++++----------- 7 files changed, 44 insertions(+), 48 deletions(-) diff --git a/docs/api.md b/docs/api.md index 763a7f7c..43204ab3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -598,7 +598,7 @@ Add a card to the game (debug/testing). Supports jokers, consumables, vouchers, | ------------- | ------- | -------- | ------------------------------------------------------------------------------ | | `key` | string | Yes | [Card key](#card-keys) (e.g., `j_joker`, `c_fool`, `p_arcana_normal_1`, `H_A`) | | `seal` | string | No | [Seal](#card-modifier-seal) type (playing cards only) | -| `edition` | string | No | [Edition](#card-modifier-edition) type (not vouchers or packs) | +| `edition` | string | No | [Edition](#card-modifier-edition) key (e.g. `e_foil`, not vouchers or packs) | | `enhancement` | string | No | [Enhancement](#card-modifier-enhancement) type (playing cards only) | | `eternal` | boolean | No | Cannot be sold/destroyed (jokers only) | | `perishable` | integer | No | Rounds until perish (jokers only) | @@ -616,7 +616,7 @@ Add a card to the game (debug/testing). Supports jokers, consumables, vouchers, # Add a Polychrome Joker curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "add", "params": {"key": "j_joker", "edition": "POLYCHROME"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "add", "params": {"key": "j_joker", "edition": "e_polychrome"}, "id": 1}' # Add an Arcana Pack to the shop (requires SHOP state) curl -X POST http://127.0.0.1:12346 \ @@ -910,12 +910,12 @@ Represents a Balatro tag that provides bonuses when triggered. ### Card Modifier Edition -| Value | Description | -| ------------ | --------------------------------- | -| `FOIL` | +50 Chips | -| `HOLO` | +10 Mult | -| `POLYCHROME` | X1.5 Mult | -| `NEGATIVE` | +1 slot (jokers/consumables only) | +| Value | Description | +| -------------- | --------------------------------- | +| `e_foil` | +50 Chips | +| `e_holo` | +10 Mult | +| `e_polychrome` | X1.5 Mult | +| `e_negative` | +1 slot (jokers/consumables only) | ### Card Modifier Enhancement diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 6e35b8a3..15bc6641 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -7,7 +7,7 @@ ---@class Request.Endpoint.Add.Params ---@field key Card.Key The card key to add (j_* for jokers, c_* for consumables, v_* for vouchers, SUIT_RANK for playing cards) ---@field seal Card.Modifier.Seal? The card seal to apply (only for playing cards) ----@field edition Card.Modifier.Edition? The card edition to apply (jokers, playing cards and NEGATIVE consumables) +---@field edition Card.Modifier.Edition? The card edition to apply (jokers, playing cards and e_negative consumables) ---@field enhancement Card.Modifier.Enhancement? The card enhancement to apply (playing cards) ---@field eternal boolean? If true, the card will be eternal (jokers only) ---@field perishable integer? The card will be perishable for this many rounds (jokers only, must be >= 1) @@ -50,14 +50,6 @@ local SEAL_MAP = { PURPLE = "Purple", } --- Edition conversion table -local EDITION_MAP = { - HOLO = "e_holo", - FOIL = "e_foil", - POLYCHROME = "e_polychrome", - NEGATIVE = "e_negative", -} - ---Detect card type based on key prefix or pattern ---@param key string The card key ---@return string|nil card_type The detected card type or nil if invalid @@ -125,7 +117,7 @@ return { edition = { type = "string", required = false, - description = "Edition type (HOLO, FOIL, POLYCHROME, NEGATIVE) - valid for jokers, playing cards, and consumables (consumables: NEGATIVE only)", + description = "Edition key (e_foil, e_holo, e_polychrome, e_negative) - valid for jokers, playing cards, and consumables (consumables: e_negative only)", }, enhancement = { type = "string", @@ -258,10 +250,10 @@ return { return end - -- Special validation: consumables can only have NEGATIVE edition - if args.edition and card_type == "consumable" and args.edition ~= "NEGATIVE" then + -- Special validation: consumables can only have e_negative edition + if args.edition and card_type == "consumable" and args.edition ~= "e_negative" then send_response({ - message = "Consumables can only have NEGATIVE edition", + message = "Consumables can only have e_negative edition", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -270,14 +262,14 @@ return { -- Validate and convert edition value local edition_value = nil if args.edition then - edition_value = EDITION_MAP[args.edition] - if not edition_value then + if args.edition:sub(1, 2) ~= "e_" then send_response({ - message = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", + message = "Expected an e_* edition key (e.g. e_foil, e_holo)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + edition_value = args.edition end -- Validate enhancement parameter is only for playing cards diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 1d23a3ef..c55f644e 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -385,10 +385,10 @@ ---| "PURPLE" # Creates a Tarot card when discarded (Must have room) ---@alias Card.Modifier.Edition ----| "HOLO" # +10 Mult when scored (Playing cards). +10 Mult directly before the Joker is reached during scoring (Jokers) ----| "FOIL" # +50 Chips when scored (Playing cards). +50 Chips directly before the Joker is reached during scoring (Jokers) ----| "POLYCHROME" # X1.5 Mult when scored (Playing cards). X1.5 Mult directly after the Joker is reached during scoring (Jokers) ----| "NEGATIVE" # N/A (Playing cards). +1 Joker slot (Jokers). +1 Consumable slot (Consumables) +---| "e_holo" # +10 Mult when scored (Playing cards). +10 Mult directly before the Joker is reached during scoring (Jokers) +---| "e_foil" # +50 Chips when scored (Playing cards). +50 Chips directly before the Joker is reached during scoring (Jokers) +---| "e_polychrome" # X1.5 Mult when scored (Playing cards). X1.5 Mult directly after the Joker is reached during scoring (Jokers) +---| "e_negative" # N/A (Playing cards). +1 Joker slot (Jokers). +1 Consumable slot (Consumables) ---@alias Card.Modifier.Enhancement ---| "m_bonus" # Enhanced card gives an additional +30 Chips when scored diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 05fc5e02..bfb82ef9 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -232,9 +232,9 @@ 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 (table with key) + if card.edition and card.edition.key then + modifier.edition = card.edition.key end -- Enhancement (from center_key for enhanced cards) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 910496ea..92cf95ac 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -62,7 +62,7 @@ }, { "name": "edition", - "description": "Edition type. NEGATIVE only valid for consumables; jokers and playing cards accept all editions. Not valid for vouchers.", + "description": "Edition key (e_foil, e_holo, e_polychrome, e_negative). e_negative only valid for consumables; jokers and playing cards accept all editions. Not valid for vouchers.", "required": false, "schema": { "$ref": "#/components/schemas/Edition" @@ -1582,19 +1582,19 @@ "description": "Card edition type", "oneOf": [ { - "const": "FOIL", + "const": "e_foil", "description": "+50 Chips when scored" }, { - "const": "HOLO", + "const": "e_holo", "description": "+10 Mult when scored" }, { - "const": "POLYCHROME", + "const": "e_polychrome", "description": "X1.5 Mult when scored" }, { - "const": "NEGATIVE", + "const": "e_negative", "description": "+1 Joker slot (Jokers) or +1 Consumable slot (Consumables)" } ] diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index bda487af..605653cf 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -85,7 +85,7 @@ ---@class Card.Modifier ---@field seal Card.Modifier.Seal? Seal type (playing cards) ----@field edition Card.Modifier.Edition? Edition type (jokers, playing cards and NEGATIVE consumables) +---@field edition Card.Modifier.Edition? Edition type (jokers, playing cards and e_negative consumables) ---@field enhancement Card.Modifier.Enhancement? Enhancement type (playing cards) ---@field eternal boolean? If true, card cannot be sold or destroyed (jokers only) ---@field perishable integer? Number of rounds remaining (only if > 0) (jokers only) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 5114a2ba..0a897158 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -321,7 +321,9 @@ def test_add_non_playing_card_with_seal_fails( class TestAddEndpointEdition: """Test edition parameter for add endpoint.""" - @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) + @pytest.mark.parametrize( + "edition", ["e_holo", "e_foil", "e_polychrome", "e_negative"] + ) def test_add_joker_with_edition(self, client: httpx.Client, edition: str) -> None: """Test adding a joker with various editions.""" gamestate = load_fixture( @@ -337,7 +339,9 @@ def test_add_joker_with_edition(self, client: httpx.Client, edition: str) -> Non assert after["jokers"]["cards"][0]["key"] == "j_joker" assert after["jokers"]["cards"][0]["modifier"]["edition"] == edition - @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) + @pytest.mark.parametrize( + "edition", ["e_holo", "e_foil", "e_polychrome", "e_negative"] + ) def test_add_playing_card_with_edition( self, client: httpx.Client, edition: str ) -> None: @@ -356,7 +360,7 @@ def test_add_playing_card_with_edition( assert after["hand"]["cards"][8]["modifier"]["edition"] == edition def test_add_consumable_with_negative_edition(self, client: httpx.Client) -> None: - """Test adding a consumable with NEGATIVE edition (only valid edition for consumables).""" + """Test adding a consumable with e_negative edition (only valid edition for consumables).""" gamestate = load_fixture( client, "add", @@ -364,17 +368,17 @@ def test_add_consumable_with_negative_edition(self, client: httpx.Client) -> Non ) assert gamestate["state"] == "SHOP" assert gamestate["consumables"]["count"] == 0 - response = api(client, "add", {"key": "c_fool", "edition": "NEGATIVE"}) + response = api(client, "add", {"key": "c_fool", "edition": "e_negative"}) after = assert_gamestate_response(response) assert after["consumables"]["count"] == 1 assert after["consumables"]["cards"][0]["key"] == "c_fool" - assert after["consumables"]["cards"][0]["modifier"]["edition"] == "NEGATIVE" + assert after["consumables"]["cards"][0]["modifier"]["edition"] == "e_negative" - @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME"]) + @pytest.mark.parametrize("edition", ["e_holo", "e_foil", "e_polychrome"]) def test_add_consumable_with_non_negative_edition_fails( self, client: httpx.Client, edition: str ) -> None: - """Test that adding a consumable with HOLO | FOIL | POLYCHROME edition fails.""" + """Test that adding a consumable with e_holo | e_foil | e_polychrome edition fails.""" gamestate = load_fixture( client, "add", @@ -386,7 +390,7 @@ def test_add_consumable_with_non_negative_edition_fails( assert_error_response( response, "BAD_REQUEST", - "Consumables can only have NEGATIVE edition", + "Consumables can only have e_negative edition", ) def test_add_voucher_with_edition_fails(self, client: httpx.Client) -> None: @@ -398,7 +402,7 @@ def test_add_voucher_with_edition_fails(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "SHOP" assert gamestate["vouchers"]["count"] == 0 - response = api(client, "add", {"key": "v_overstock_norm", "edition": "FOIL"}) + response = api(client, "add", {"key": "v_overstock_norm", "edition": "e_foil"}) assert_error_response( response, "BAD_REQUEST", "Edition cannot be applied to vouchers" ) @@ -411,7 +415,7 @@ def test_add_pack_with_edition_fails(self, client: httpx.Client) -> None: "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0--packs.count-0", ) assert gamestate["state"] == "SHOP" - response = api(client, "add", {"key": "p_arcana_normal_1", "edition": "FOIL"}) + response = api(client, "add", {"key": "p_arcana_normal_1", "edition": "e_foil"}) assert_error_response( response, "BAD_REQUEST", "Edition cannot be applied to packs" ) @@ -429,7 +433,7 @@ def test_add_playing_card_invalid_edition(self, client: httpx.Client) -> None: assert_error_response( response, "BAD_REQUEST", - "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", + "Expected an e_* edition key (e.g. e_foil, e_holo)", ) From 6aba7903f568befd305eacf7a66bb0268d355c86 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 11:19:58 +0200 Subject: [PATCH 103/121] feat(lua): migrate Seal enum from ALL-CAPS to Capitalized keys Replace custom ALL-CAPS seal values (RED, BLUE, GOLD, PURPLE) with Balatro's in-game G.P_SEALS keys (Red, Blue, Gold, Purple) so the API reads and writes card.seal with zero conversion. This is step 3 of the enum migration plan. Steps 1 (Enhancement) and 2 (Edition) were completed in previous commits. Deck and Stake remain unmigrated (later steps). Changes: - enums.lua: update Card.Modifier.Seal alias to Capitalized values - add.lua: remove SEAL_MAP lookup table, validate against known Capitalized values directly instead - gamestate.lua: remove string.upper() wrapping on card.seal - openrpc.json: update Seal const values to Capitalized - docs/api.md: update Seal enum table - tests: update seal test values from ALL-CAPS to Capitalized --- docs/api.md | 8 ++++---- src/lua/endpoints/add.lua | 19 ++++++------------- src/lua/utils/enums.lua | 8 ++++---- src/lua/utils/gamestate.lua | 2 +- src/lua/utils/openrpc.json | 8 ++++---- tests/lua/endpoints/test_add.py | 6 +++--- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/docs/api.md b/docs/api.md index 43204ab3..44d83485 100644 --- a/docs/api.md +++ b/docs/api.md @@ -903,10 +903,10 @@ Represents a Balatro tag that provides bonuses when triggered. | Value | Description | | -------- | ------------------------------------------ | -| `RED` | Retrigger card 1 time | -| `BLUE` | Creates Planet card for final hand if held | -| `GOLD` | Earn $3 when scored | -| `PURPLE` | Creates Tarot when discarded | +| `Red` | Retrigger card 1 time | +| `Blue` | Creates Planet card for final hand if held | +| `Gold` | Earn $3 when scored | +| `Purple` | Creates Tarot when discarded | ### Card Modifier Edition diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 15bc6641..05621d04 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -6,7 +6,7 @@ ---@class Request.Endpoint.Add.Params ---@field key Card.Key The card key to add (j_* for jokers, c_* for consumables, v_* for vouchers, SUIT_RANK for playing cards) ----@field seal Card.Modifier.Seal? The card seal to apply (only for playing cards) +---@field seal Card.Modifier.Seal? Seal type from G.P_SEALS (e.g. Red, Blue, Gold, Purple) - only valid for playing cards ---@field edition Card.Modifier.Edition? The card edition to apply (jokers, playing cards and e_negative consumables) ---@field enhancement Card.Modifier.Enhancement? The card enhancement to apply (playing cards) ---@field eternal boolean? If true, the card will be eternal (jokers only) @@ -42,13 +42,6 @@ local RANK_MAP = { A = "Ace", } --- Seal conversion table -local SEAL_MAP = { - RED = "Red", - BLUE = "Blue", - GOLD = "Gold", - PURPLE = "Purple", -} ---Detect card type based on key prefix or pattern ---@param key string The card key @@ -112,7 +105,7 @@ return { seal = { type = "string", required = false, - description = "Seal type (RED, BLUE, GOLD, PURPLE) - only valid for playing cards", + description = "Seal type from G.P_SEALS (e.g. Red, Blue, Gold, Purple) - only valid for playing cards", }, edition = { type = "string", @@ -228,17 +221,17 @@ return { return end - -- Validate and convert seal value + -- Validate seal value local seal_value = nil if args.seal then - seal_value = SEAL_MAP[args.seal] - if not seal_value then + if args.seal ~= "Red" and args.seal ~= "Blue" and args.seal ~= "Gold" and args.seal ~= "Purple" then send_response({ - message = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", + message = "Invalid seal value. Expected a Seal key from G.P_SEALS (e.g. Red, Blue)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + seal_value = args.seal end -- Validate edition parameter is only for jokers, playing cards, or consumables diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index c55f644e..0481f6ab 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -379,10 +379,10 @@ ---| Card.Key.Pack ---@alias Card.Modifier.Seal ----| "RED" # Retrigger this card 1 time ----| "BLUE" # Creates the Planet card for final played poker hand of round if held in hand (Must have room) ----| "GOLD" # Earn $3 when this card is played and scores ----| "PURPLE" # Creates a Tarot card when discarded (Must have room) +---| "Red" # Retrigger this card 1 time +---| "Blue" # Creates the Planet card for final played poker hand of round if held in hand (Must have room) +---| "Gold" # Earn $3 when this card is played and scores +---| "Purple" # Creates a Tarot card when discarded (Must have room) ---@alias Card.Modifier.Edition ---| "e_holo" # +10 Mult when scored (Playing cards). +10 Mult directly before the Joker is reached during scoring (Jokers) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index bfb82ef9..27616a6e 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -229,7 +229,7 @@ local function extract_card_modifier(card) -- Seal (direct property) if card.seal then - modifier.seal = string.upper(card.seal) + modifier.seal = card.seal end -- Edition (table with key) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 92cf95ac..25681634 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -1561,19 +1561,19 @@ "description": "Card seal type", "oneOf": [ { - "const": "RED", + "const": "Red", "description": "Retrigger this card 1 time" }, { - "const": "BLUE", + "const": "Blue", "description": "Creates Planet card for final played poker hand if held in hand" }, { - "const": "GOLD", + "const": "Gold", "description": "Earn $3 when this card is played and scores" }, { - "const": "PURPLE", + "const": "Purple", "description": "Creates a Tarot card when discarded" } ] diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 0a897158..85c7fd84 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -265,7 +265,7 @@ def test_add_pack_shop_full(self, client: httpx.Client) -> None: class TestAddEndpointSeal: """Test seal parameter for add endpoint.""" - @pytest.mark.parametrize("seal", ["RED", "BLUE", "GOLD", "PURPLE"]) + @pytest.mark.parametrize("seal", ["Red", "Blue", "Gold", "Purple"]) def test_add_playing_card_with_seal(self, client: httpx.Client, seal: str) -> None: """Test adding a playing card with various seals.""" gamestate = load_fixture( @@ -294,7 +294,7 @@ def test_add_playing_card_invalid_seal(self, client: httpx.Client) -> None: assert_error_response( response, "BAD_REQUEST", - "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", + "Invalid seal value. Expected a Seal key from G.P_SEALS (e.g. Red, Blue)", ) @pytest.mark.parametrize( @@ -310,7 +310,7 @@ def test_add_non_playing_card_with_seal_fails( "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0--packs.count-0", ) assert gamestate["state"] == "SHOP" - response = api(client, "add", {"key": key, "seal": "RED"}) + response = api(client, "add", {"key": key, "seal": "Red"}) assert_error_response( response, "BAD_REQUEST", From 2736fd62b8aaacefb6a00f0fef99ade96856a967 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 11:57:45 +0200 Subject: [PATCH 104/121] feat(lua): migrate Deck enum from ALL-CAPS to b_* keys Replace custom ALL-CAPS deck values (RED, BLUE, YELLOW, etc.) with Balatro's in-game G.P_CENTERS keys (b_red, b_blue, b_yellow, etc.) so the API reads, writes, and starts runs using real game registry keys instead of invented aliases. This is step 4 of the enum migration plan. Stake remains unmigrated (final step). Source changes: - enums.lua: update Deck alias to b_* values - start.lua: remove DECK_ENUM_TO_NAME map, validate via G.P_CENTERS lookup instead, use deck_data.key for matching - gamestate.lua: remove DECK_KEY_TO_NAME map and get_deck_name(), return raw deck_key from selected_back.effect.center.key - openrpc.json: update Deck const values to b_* - add.lua: remove extraneous blank line Documentation: - docs/api.md: update Deck enum table - docs/cli.md: update start example to use b_red - docs/example-bot.md: update start example to use b_red Tests: - tests/fixtures/fixtures.json: update deck params and fixture names - tests/lua/endpoints/test_gamestate.py: update fixture refs and assertions - tests/lua/endpoints/test_pack.py: update start param - tests/lua/endpoints/test_start.py: update params and assertions - tests/cli/test_api_cmd.py: update start param --- docs/api.md | 40 +++--- docs/cli.md | 2 +- docs/example-bot.md | 2 +- src/lua/endpoints/add.lua | 1 - src/lua/endpoints/start.lua | 43 ++----- src/lua/utils/enums.lua | 30 ++--- src/lua/utils/gamestate.lua | 31 +---- src/lua/utils/openrpc.json | 60 ++++----- tests/cli/test_api_cmd.py | 2 +- tests/fixtures/fixtures.json | 178 +++++++++++++------------- tests/lua/endpoints/test_gamestate.py | 60 ++++----- tests/lua/endpoints/test_pack.py | 2 +- tests/lua/endpoints/test_start.py | 32 ++--- 13 files changed, 217 insertions(+), 266 deletions(-) diff --git a/docs/api.md b/docs/api.md index 44d83485..9a80f452 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,7 +68,7 @@ curl -X POST http://127.0.0.1:12346 \ ```bash curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "RED", "stake": "WHITE"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_red", "stake": "WHITE"}, "id": 1}' ``` #### 4. Select Blind and Play Cards @@ -208,7 +208,7 @@ Start a new game run. ```bash curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "BLUE", "stake": "WHITE", "seed": "TEST123"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_blue", "stake": "WHITE", "seed": "TEST123"}, "id": 1}' ``` --- @@ -699,7 +699,7 @@ The complete game state returned by most methods. "round_num": 1, "ante_num": 1, "money": 4, - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "ABC123", "won": false, @@ -828,23 +828,23 @@ Represents a Balatro tag that provides bonuses when triggered. ### Deck -| Value | Description | -| ----------- | ------------------------------------------------------------- | -| `RED` | +1 discard every round | -| `BLUE` | +1 hand every round | -| `YELLOW` | Start with extra $10 | -| `GREEN` | $2 per remaining Hand, $1 per remaining Discard (no interest) | -| `BLACK` | +1 Joker slot, -1 hand every round | -| `MAGIC` | Start with Crystal Ball voucher and 2 copies of The Fool | -| `NEBULA` | Start with Telescope voucher, -1 consumable slot | -| `GHOST` | Spectral cards may appear in shop, start with Hex card | -| `ABANDONED` | Start with no Face Cards | -| `CHECKERED` | Start with 26 Spades and 26 Hearts | -| `ZODIAC` | Start with Tarot Merchant, Planet Merchant, and Overstock | -| `PAINTED` | +2 hand size, -1 Joker slot | -| `ANAGLYPH` | Gain Double Tag after each Boss Blind | -| `PLASMA` | Balanced Chips/Mult, 2X base Blind size | -| `ERRATIC` | Randomized Ranks and Suits | +| Value | Description | +| ------------- | ------------------------------------------------------------- | +| `b_red` | +1 discard every round | +| `b_blue` | +1 hand every round | +| `b_yellow` | Start with extra $10 | +| `b_green` | $2 per remaining Hand, $1 per remaining Discard (no interest) | +| `b_black` | +1 Joker slot, -1 hand every round | +| `b_magic` | Start with Crystal Ball voucher and 2 copies of The Fool | +| `b_nebula` | Start with Telescope voucher, -1 consumable slot | +| `b_ghost` | Spectral cards may appear in shop, start with Hex card | +| `b_abandoned` | Start with no Face Cards | +| `b_checkered` | Start with 26 Spades and 26 Hearts | +| `b_zodiac` | Start with Tarot Merchant, Planet Merchant, and Overstock | +| `b_painted` | +2 hand size, -1 Joker slot | +| `b_anaglyph` | Gain Double Tag after each Boss Blind | +| `b_plasma` | Balanced Chips/Mult, 2X base Blind size | +| `b_erratic` | Randomized Ranks and Suits | ### Stake diff --git a/docs/cli.md b/docs/cli.md index e3e0c5c7..9c33faef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -109,7 +109,7 @@ uvx balatrobot api health uvx balatrobot api gamestate # Start a new game with Red Deck -uvx balatrobot api start '{"deck": "RED", "stake": "WHITE"}' +uvx balatrobot api start '{"deck": "b_red", "stake": "WHITE"}' # Play cards at indices 0 and 2 uvx balatrobot api play '{"cards": [0, 2]}' diff --git a/docs/example-bot.md b/docs/example-bot.md index 4a758bd7..9e22981f 100644 --- a/docs/example-bot.md +++ b/docs/example-bot.md @@ -36,7 +36,7 @@ def play_game(): """Play a complete game of Balatro.""" # Return to menu and start a new game rpc("menu") - state = rpc("start", {"deck": "RED", "stake": "WHITE"}) + state = rpc("start", {"deck": "b_red", "stake": "WHITE"}) print(f"Started game with seed: {state['seed']}") # Main game loop diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 05621d04..5a42bd1a 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -42,7 +42,6 @@ local RANK_MAP = { A = "Ace", } - ---Detect card type based on key prefix or pattern ---@param key string The card key ---@return string|nil card_type The detected card type or nil if invalid diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 13883ad4..373ed7c5 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -5,7 +5,7 @@ -- ========================================================================== ---@class Request.Endpoint.Start.Params ----@field deck Deck deck enum value (e.g., "RED", "BLUE", "YELLOW") +---@field deck Deck Deck key from G.P_CENTERS (e.g., "b_red", "b_blue") ---@field stake Stake stake enum value (e.g., "WHITE", "RED", "GREEN", "BLACK", "BLUE", "PURPLE", "ORANGE", "GOLD") ---@field seed string? optional seed for the run @@ -13,24 +13,6 @@ -- Start Endpoint Utils -- ========================================================================== -local DECK_ENUM_TO_NAME = { - RED = "Red Deck", - BLUE = "Blue Deck", - YELLOW = "Yellow Deck", - GREEN = "Green Deck", - BLACK = "Black Deck", - MAGIC = "Magic Deck", - NEBULA = "Nebula Deck", - GHOST = "Ghost Deck", - ABANDONED = "Abandoned Deck", - CHECKERED = "Checkered Deck", - ZODIAC = "Zodiac Deck", - PAINTED = "Painted Deck", - ANAGLYPH = "Anaglyph Deck", - PLASMA = "Plasma Deck", - ERRATIC = "Erratic Deck", -} - local STAKE_ENUM_TO_NUMBER = { WHITE = 1, RED = 2, @@ -57,7 +39,7 @@ return { deck = { type = "string", required = true, - description = "Deck enum value (e.g., 'RED', 'BLUE', 'YELLOW')", + description = "Deck key from G.P_CENTERS (e.g., 'b_red', 'b_blue')", }, stake = { type = "string", @@ -90,13 +72,12 @@ return { return end - -- Validate and map deck enum - local deck_name = DECK_ENUM_TO_NAME[args.deck] - if not deck_name then - sendWarnMessage("Invalid deck enum: " .. tostring(args.deck), "BB.ENDPOINTS") + -- Validate deck key against G.P_CENTERS + local deck_center = G.P_CENTERS and G.P_CENTERS[args.deck] + if not deck_center or deck_center.set ~= "Back" then + sendWarnMessage("Invalid deck key: " .. tostring(args.deck), "BB.ENDPOINTS") send_response({ - message = "Invalid deck enum. Must be one of: RED, BLUE, YELLOW, GREEN, BLACK, MAGIC, NEBULA, GHOST, ABANDONED, CHECKERED, ZODIAC, PAINTED, ANAGLYPH, PLASMA, ERRATIC. Got: " - .. tostring(args.deck), + message = "Expected a b_* deck key from G.P_CENTERS (e.g. b_red, b_blue). Got: " .. tostring(args.deck), name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -106,11 +87,11 @@ return { G.FUNCS.setup_run({ config = {} }) G.FUNCS.exit_overlay_menu() - -- Find and set the deck using the mapped deck name + -- Find and set the deck using the deck key local deck_found = false if G.P_CENTER_POOLS and G.P_CENTER_POOLS.Back then for _, deck_data in pairs(G.P_CENTER_POOLS.Back) do - if deck_data.name == deck_name then + if deck_data.key == args.deck then G.GAME.selected_back:change_to(deck_data) G.GAME.viewed_back:change_to(deck_data) deck_found = true @@ -120,9 +101,9 @@ return { end if not deck_found then - sendWarnMessage("Deck not found: " .. deck_name, "BB.ENDPOINTS") + sendWarnMessage("Deck not found in G.P_CENTER_POOLS.Back: " .. args.deck, "BB.ENDPOINTS") send_response({ - message = "Deck not found in game data: " .. deck_name, + message = "Deck not found in game data: " .. args.deck, name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return @@ -136,7 +117,7 @@ return { sendInfoMessage( "Starting run: " - .. deck_name + .. args.deck .. ", stake=" .. tostring(stake_number) .. " (" diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 0481f6ab..3929376f 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -1,21 +1,21 @@ ---@meta enums ---@alias Deck ----| "RED" # +1 discard every round ----| "BLUE" # +1 hand every round ----| "YELLOW" # Start with extra $10 ----| "GREEN" # At end of each Round, $2 per remaining Hand $1 per remaining Discard Earn no Interest ----| "BLACK" # +1 Joker slot -1 hand every round ----| "MAGIC" # Start run with the Cristal Ball voucher and 2 copies of The Fool ----| "NEBULA" # Start run with the Telescope voucher and -1 consumable slot ----| "GHOST" # Spectral cards may appear in the shop. Start with a Hex card ----| "ABANDONED" # Start run with no Face Cards in your deck ----| "CHECKERED" # Start run with 26 Spaces and 26 Hearts in deck ----| "ZODIAC" # Start run with Tarot Merchant, Planet Merchant, and Overstock ----| "PAINTED" # +2 hand size, -1 Joker slot ----| "ANAGLYPH" # After defeating each Boss Blind, gain a Double Tag ----| "PLASMA" # Balanced Chips and Mult when calculating score for played hand X2 base Blind size ----| "ERRATIC" # All Ranks and Suits in deck are randomized +---| "b_red" # Red Deck: +1 discard every round +---| "b_blue" # Blue Deck: +1 hand every round +---| "b_yellow" # Yellow Deck: Start with extra $10 +---| "b_green" # Green Deck: $2 per remaining Hand, $1 per remaining Discard, no interest +---| "b_black" # Black Deck: +1 Joker slot, -1 hand every round +---| "b_magic" # Magic Deck: Start with Crystal Ball and 2 copies of The Fool +---| "b_nebula" # Nebula Deck: Start with Telescope, -1 consumable slot +---| "b_ghost" # Ghost Deck: Spectral cards may appear in shop, start with Hex +---| "b_abandoned" # Abandoned Deck: No Face Cards in starting deck +---| "b_checkered" # Checkered Deck: 26 Spades and 26 Hearts in deck +---| "b_zodiac" # Zodiac Deck: Start with Tarot Merchant, Planet Merchant, and Overstock +---| "b_painted" # Painted Deck: +2 hand size, -1 Joker slot +---| "b_anaglyph" # Anaglyph Deck: Double Tag after each Boss Blind +---| "b_plasma" # Plasma Deck: Balanced Chips/Mult, 2X base Blind size +---| "b_erratic" # Erratic Deck: Random Ranks and Suits ---@alias Stake ---| "WHITE" # 1. Base Difficulty diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 27616a6e..db619adc 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -30,35 +30,6 @@ local function get_state_name(state_num) return "UNKNOWN" end --- ========================================================================== --- Deck Name Mapping --- ========================================================================== - -local DECK_KEY_TO_NAME = { - b_red = "RED", - b_blue = "BLUE", - b_yellow = "YELLOW", - b_green = "GREEN", - b_black = "BLACK", - b_magic = "MAGIC", - b_nebula = "NEBULA", - b_ghost = "GHOST", - b_abandoned = "ABANDONED", - b_checkered = "CHECKERED", - b_zodiac = "ZODIAC", - b_painted = "PAINTED", - b_anaglyph = "ANAGLYPH", - b_plasma = "PLASMA", - b_erratic = "ERRATIC", -} - ----Converts deck key to string deck name ----@param deck_key string The key from G.P_CENTERS (e.g., "b_red") ----@return string? deck_name The string name of the deck (e.g., "RED"), or nil if not found -local function get_deck_name(deck_key) - return DECK_KEY_TO_NAME[deck_key] -end - -- ========================================================================== -- Stake Name Mapping -- ========================================================================== @@ -855,7 +826,7 @@ function gamestate.get_gamestate() -- Deck (optional) if G.GAME.selected_back and G.GAME.selected_back.effect and G.GAME.selected_back.effect.center then local deck_key = G.GAME.selected_back.effect.center.key - state_data.deck = get_deck_name(deck_key) + state_data.deck = deck_key end -- Stake (optional) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 25681634..37e6a058 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -1381,64 +1381,64 @@ "description": "Deck type", "oneOf": [ { - "const": "RED", - "description": "+1 discard every round" + "const": "b_red", + "description": "Red Deck: +1 discard every round" }, { - "const": "BLUE", - "description": "+1 hand every round" + "const": "b_blue", + "description": "Blue Deck: +1 hand every round" }, { - "const": "YELLOW", - "description": "Start with extra $10" + "const": "b_yellow", + "description": "Yellow Deck: Start with extra $10" }, { - "const": "GREEN", - "description": "$2 per remaining Hand, $1 per remaining Discard, no interest" + "const": "b_green", + "description": "Green Deck: $2 per remaining Hand, $1 per remaining Discard, no interest" }, { - "const": "BLACK", - "description": "+1 Joker slot, -1 hand every round" + "const": "b_black", + "description": "Black Deck: +1 Joker slot, -1 hand every round" }, { - "const": "MAGIC", - "description": "Start with Crystal Ball voucher and 2 copies of The Fool" + "const": "b_magic", + "description": "Magic Deck: Start with Crystal Ball and 2 copies of The Fool" }, { - "const": "NEBULA", - "description": "Start with Telescope voucher, -1 consumable slot" + "const": "b_nebula", + "description": "Nebula Deck: Start with Telescope, -1 consumable slot" }, { - "const": "GHOST", - "description": "Spectral cards may appear in shop, start with Hex card" + "const": "b_ghost", + "description": "Ghost Deck: Spectral cards may appear in shop, start with Hex" }, { - "const": "ABANDONED", - "description": "Start with no Face Cards in deck" + "const": "b_abandoned", + "description": "Abandoned Deck: No Face Cards in starting deck" }, { - "const": "CHECKERED", - "description": "Start with 26 Spades and 26 Hearts in deck" + "const": "b_checkered", + "description": "Checkered Deck: 26 Spades and 26 Hearts in deck" }, { - "const": "ZODIAC", - "description": "Start with Tarot Merchant, Planet Merchant, and Overstock" + "const": "b_zodiac", + "description": "Zodiac Deck: Start with Tarot Merchant, Planet Merchant, and Overstock" }, { - "const": "PAINTED", - "description": "+2 hand size, -1 Joker slot" + "const": "b_painted", + "description": "Painted Deck: +2 hand size, -1 Joker slot" }, { - "const": "ANAGLYPH", - "description": "Gain Double Tag after each Boss Blind" + "const": "b_anaglyph", + "description": "Anaglyph Deck: Double Tag after each Boss Blind" }, { - "const": "PLASMA", - "description": "Balanced Chips and Mult, 2X base Blind size" + "const": "b_plasma", + "description": "Plasma Deck: Balanced Chips/Mult, 2X base Blind size" }, { - "const": "ERRATIC", - "description": "All Ranks and Suits in deck are randomized" + "const": "b_erratic", + "description": "Erratic Deck: Random Ranks and Suits" } ] }, diff --git a/tests/cli/test_api_cmd.py b/tests/cli/test_api_cmd.py index 91592026..5fc430d2 100644 --- a/tests/cli/test_api_cmd.py +++ b/tests/cli/test_api_cmd.py @@ -38,7 +38,7 @@ def test_api_gamestate_success(self, cli_port: int, balatro_client: BalatroClien def test_api_with_params(self, cli_port: int, balatro_client: BalatroClient): """api command passes JSON params correctly.""" balatro_client.call("menu") - params = json.dumps({"deck": "RED", "stake": "WHITE"}) + params = json.dumps({"deck": "b_red", "stake": "WHITE"}) result = runner.invoke( app, ["api", "start", params, "--port", str(cli_port), "--host", "127.0.0.1"], diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index cfb3cfc8..7c3a3016 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -9,7 +9,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -17,7 +17,7 @@ ] }, "gamestate": { - "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE": [ + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE": [ { "method": "menu", "params": {} @@ -25,13 +25,13 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } } ], - "state-BLIND_SELECT--deck-BLUE--stake-RED": [ + "state-BLIND_SELECT--deck-b_blue--stake-RED": [ { "method": "menu", "params": {} @@ -39,7 +39,7 @@ { "method": "start", "params": { - "deck": "BLUE", + "deck": "b_blue", "stake": "RED", "seed": "TEST123" } @@ -53,7 +53,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -86,7 +86,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -120,7 +120,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -156,7 +156,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "QKNXF682" } @@ -199,7 +199,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -219,7 +219,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -235,7 +235,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -251,7 +251,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -265,7 +265,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -283,7 +283,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -321,7 +321,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -337,7 +337,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -353,7 +353,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -367,7 +367,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -385,7 +385,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -409,7 +409,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -423,7 +423,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -441,7 +441,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -465,7 +465,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -479,7 +479,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -497,7 +497,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -523,7 +523,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -547,7 +547,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -580,7 +580,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -614,7 +614,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -628,7 +628,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -646,7 +646,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -670,7 +670,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -704,7 +704,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -718,7 +718,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -752,7 +752,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -766,7 +766,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -804,7 +804,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -818,7 +818,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -854,7 +854,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -898,7 +898,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -912,7 +912,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -948,7 +948,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -984,7 +984,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1020,7 +1020,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1056,7 +1056,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1098,7 +1098,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1138,7 +1138,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1186,7 +1186,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1265,7 +1265,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1330,7 +1330,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1372,7 +1372,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1435,7 +1435,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1472,7 +1472,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1533,7 +1533,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1618,7 +1618,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -1693,7 +1693,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "QKNXF682" } @@ -1736,7 +1736,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "3Q1KBVT3" } @@ -1779,7 +1779,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "3Q1KBVT3" } @@ -1834,7 +1834,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "VPV32ZTY" } @@ -1877,7 +1877,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "VEBROR8" } @@ -1950,7 +1950,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "7IDNRIV" } @@ -2023,7 +2023,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TAGTEST2" } @@ -2047,7 +2047,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2061,7 +2061,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2093,7 +2093,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2111,7 +2111,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2160,7 +2160,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2217,7 +2217,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "INVIS3" } @@ -2293,7 +2293,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2307,7 +2307,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2325,7 +2325,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2361,7 +2361,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2430,7 +2430,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2444,7 +2444,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2477,7 +2477,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2507,7 +2507,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2531,7 +2531,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2573,7 +2573,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2597,7 +2597,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2645,7 +2645,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2706,7 +2706,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2730,7 +2730,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2773,7 +2773,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2818,7 +2818,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2832,7 +2832,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2850,7 +2850,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2931,7 +2931,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } @@ -2994,7 +2994,7 @@ { "method": "start", "params": { - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "seed": "TEST123" } diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 5243fb96..53abcdf1 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -19,18 +19,18 @@ def test_gamestate_from_MENU(self, client: httpx.Client) -> None: def test_gamestate_from_BLIND_SELECT(self, client: httpx.Client) -> None: """Test that gamestate from BLIND_SELECT state is valid.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["round_num"] == 0 - assert gamestate["deck"] == "RED" + assert gamestate["deck"] == "b_red" assert gamestate["stake"] == "WHITE" response = api(client, "gamestate", {}) assert_gamestate_response( response, state="BLIND_SELECT", round_num=0, - deck="RED", + deck="b_red", stake="WHITE", ) @@ -39,47 +39,47 @@ class TestGamestateTopLevel: """Test gamestate endpoint with top-level fields.""" def test_deck_extraction(self, client: httpx.Client) -> None: - """Test deck field matches started deck (e.g., "BLUE").""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + """Test deck field matches started deck (e.g., "b_blue").""" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" gamestate = load_fixture(client, "gamestate", fixture_name) - assert gamestate["deck"] == "BLUE" + assert gamestate["deck"] == "b_blue" def test_stake_extraction(self, client: httpx.Client) -> None: """Test stake field matches started stake (e.g., "RED").""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["stake"] == "RED" def test_seed_extraction(self, client: httpx.Client) -> None: """Test seed field matches the seed used in `start`.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["seed"] == "TEST123" def test_money_extraction(self, client: httpx.Client) -> None: """Test money field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"money": 42}) assert response["result"]["seed"] == "TEST123" def test_ante_num_extractions(self, client: httpx.Client) -> None: """Test ante_num field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"ante": 5}) assert response["result"]["ante_num"] == 5 def test_round_num_extractions(self, client: httpx.Client) -> None: """Test round_num field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"round": 5}) assert response["result"]["round_num"] == 5 def test_won_false_extraction(self, client: httpx.Client) -> None: """Test won field after defeating ante 8 boss.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["won"] is False @@ -140,7 +140,7 @@ class TestGamestateBlinds: def test_blinds_structure_extraction(self, client: httpx.Client) -> None: """Test blind extraction structure.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) expected_blinds = { "small": { @@ -183,7 +183,7 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: def test_blinds_zero_skip_extraction(self, client: httpx.Client) -> None: """Test initial blind extraction.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["blinds"]["small"]["status"] == "SELECT" assert gamestate["blinds"]["big"]["status"] == "UPCOMING" @@ -191,7 +191,7 @@ def test_blinds_zero_skip_extraction(self, client: httpx.Client) -> None: def test_blinds_one_skip_extraction(self, client: httpx.Client) -> None: """Test blind extraction after one skip.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" load_fixture(client, "gamestate", fixture_name) gamestate = api(client, "skip", {})["result"] assert gamestate["blinds"]["small"]["status"] == "SKIPPED" @@ -200,7 +200,7 @@ def test_blinds_one_skip_extraction(self, client: httpx.Client) -> None: def test_blinds_two_skip_extraction(self, client: httpx.Client) -> None: """Test blind extraction after two skip.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" load_fixture(client, "gamestate", fixture_name) api(client, "skip", {}) gamestate = api(client, "skip", {})["result"] @@ -232,7 +232,7 @@ class TestGamestateAreasJokers: def test_jokers_area_empty_initial(self, client: httpx.Client) -> None: """Test jokers area is empty at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["jokers"]["count"] == 0 assert gamestate["jokers"]["cards"] == [] @@ -247,7 +247,7 @@ def test_jokers_area_count_after_add(self, client: httpx.Client) -> None: def test_jokers_area_limit(self, client: httpx.Client) -> None: """Test jokers area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["jokers"]["limit"] == 5 @@ -256,7 +256,7 @@ class TestGamestateAreasConsumables: def test_consumables_area_empty_initial(self, client: httpx.Client) -> None: """Test consumables area is empty at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["consumables"]["count"] == 0 assert gamestate["consumables"]["cards"] == [] @@ -271,7 +271,7 @@ def test_consumables_area_count_after_add(self, client: httpx.Client) -> None: def test_consumables_area_limit(self, client: httpx.Client) -> None: """Test consumables area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["consumables"]["limit"] == 2 @@ -280,20 +280,20 @@ class TestGamestateAreasCards: def test_cards_area_initial_count(self, client: httpx.Client) -> None: """Test cards area has full deck at blind selection.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["cards"]["count"] == 52 def test_cards_area_count_after_draw(self, client: httpx.Client) -> None: """Test cards area count after drawing cards.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" load_fixture(client, "gamestate", fixture_name) response = api(client, "select", {}) assert response["result"]["cards"]["count"] == 52 - 8 # 8 cards drawn def test_cards_area_limit(self, client: httpx.Client) -> None: """Test cards area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["cards"]["limit"] == 52 @@ -302,7 +302,7 @@ class TestGamestateAreasHand: def test_hand_area_count_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test hand area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["hand"]["count"] == 0 @@ -357,7 +357,7 @@ class TestGamestateAreasShop: def test_shop_area_absent_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test shop area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert "shop" not in gamestate @@ -382,7 +382,7 @@ def test_vouchers_area_absent_in_BLIND_SELECT( self, client: httpx.Client ) -> None: """Test vouchers area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert "vouchers" not in gamestate @@ -405,7 +405,7 @@ class TestGamestateAreasPacks: def test_packs_area_absent_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test packs area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) assert "packs" not in gamestate @@ -934,7 +934,7 @@ class TestGamestateTags: def test_blind_tag_structure(self, client: httpx.Client) -> None: """Test blind tag has key, name, effect fields.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) # Small blind should have a tag @@ -959,14 +959,14 @@ def test_blind_tag_structure(self, client: httpx.Client) -> None: def test_tags_empty_initially(self, client: httpx.Client) -> None: """Test tags is empty/not present at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) # tags should not be present when empty assert "tags" not in gamestate def test_tags_populated_after_skip(self, client: httpx.Client) -> None: """Test tags is populated after skipping a blind.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" load_fixture(client, "gamestate", fixture_name) # Skip the small blind to get its tag diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 2743a308..16fb902a 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -617,7 +617,7 @@ def test_pack_from_SHOP(self, client: httpx.Client) -> None: def test_pack_from_SELECTING_HAND(self, client: httpx.Client) -> None: """Test that pack fails from SELECTING_HAND state.""" api(client, "menu", {}) - api(client, "start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}) + api(client, "start", {"deck": "b_red", "stake": "WHITE", "seed": "TEST123"}) api(client, "select", {}) assert_error_response( diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 75024756..3e0a065d 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -19,23 +19,23 @@ class TestStartEndpoint: @pytest.mark.parametrize( "arguments,expected", [ - # Test basic start with RED deck and WHITE stake + # Test basic start with b_red deck and WHITE stake ( - {"deck": "RED", "stake": "WHITE"}, + {"deck": "b_red", "stake": "WHITE"}, { "state": "BLIND_SELECT", - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "ante_num": 1, "round_num": 0, }, ), - # Test with BLUE deck + # Test with b_blue deck ( - {"deck": "BLUE", "stake": "WHITE"}, + {"deck": "b_blue", "stake": "WHITE"}, { "state": "BLIND_SELECT", - "deck": "BLUE", + "deck": "b_blue", "stake": "WHITE", "ante_num": 1, "round_num": 0, @@ -43,10 +43,10 @@ class TestStartEndpoint: ), # Test with higher stake (BLACK) ( - {"deck": "RED", "stake": "BLACK"}, + {"deck": "b_red", "stake": "BLACK"}, { "state": "BLIND_SELECT", - "deck": "RED", + "deck": "b_red", "stake": "BLACK", "ante_num": 1, "round_num": 0, @@ -54,10 +54,10 @@ class TestStartEndpoint: ), # Test with seed ( - {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}, + {"deck": "b_red", "stake": "WHITE", "seed": "TEST123"}, { "state": "BLIND_SELECT", - "deck": "RED", + "deck": "b_red", "stake": "WHITE", "ante_num": 1, "round_num": 0, @@ -97,7 +97,7 @@ def test_missing_stake_parameter(self, client: httpx.Client): """Test that start fails when stake parameter is missing.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "RED"}) + response = api(client, "start", {"deck": "b_red"}) assert_error_response( response, "BAD_REQUEST", @@ -105,21 +105,21 @@ def test_missing_stake_parameter(self, client: httpx.Client): ) def test_invalid_deck_value(self, client: httpx.Client): - """Test that start fails with invalid deck enum.""" + """Test that start fails with invalid deck key.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) assert_error_response( response, "BAD_REQUEST", - "Invalid deck enum. Must be one of:", + "Expected a b_* deck key from G.P_CENTERS", ) def test_invalid_stake_value(self, client: httpx.Client): """Test that start fails when invalid stake enum is provided.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) + response = api(client, "start", {"deck": "b_red", "stake": "INVALID_STAKE"}) assert_error_response( response, "BAD_REQUEST", @@ -141,7 +141,7 @@ def test_invalid_stake_type(self, client: httpx.Client): """Test that start fails when stake is not a string.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "RED", "stake": 1}) + response = api(client, "start", {"deck": "b_red", "stake": 1}) assert_error_response( response, "BAD_REQUEST", @@ -156,7 +156,7 @@ def test_start_from_BLIND_SELECT(self, client: httpx.Client): """Test that start fails when not in MENU state.""" gamestate = load_fixture(client, "start", "state-BLIND_SELECT") assert gamestate["state"] == "BLIND_SELECT" - response = api(client, "start", {"deck": "RED", "stake": "WHITE"}) + response = api(client, "start", {"deck": "b_red", "stake": "WHITE"}) assert_error_response( response, "INVALID_STATE", From acad58381d904dd674e55b3289f96d77288488c3 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 12:15:10 +0200 Subject: [PATCH 105/121] fix(make): update `make fixtures` to use StateFile discovery Replace the `balatrobot api health` guard with the Python fixture generator's own state-file resolution via StateFile.resolve(), and remove the stale --fast flag hint in favor of --settings turbo --debug --render headfull. The hardcoded HOST/PORT constants in generate.py are replaced with dynamic host/port discovery from ~/.balatrobot/state.json, matching how `balatrobot api` resolves the target server. --- Makefile | 3 --- tests/fixtures/generate.py | 23 +++++++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index ecd1a239..19b9eb20 100644 --- a/Makefile +++ b/Makefile @@ -68,9 +68,6 @@ quality: lint typecheck format ## Run all code quality checks @$(PRINT) "$(GREEN)✓ All checks completed$(RESET)" fixtures: ## Generate fixtures - @$(PRINT) "$(YELLOW)Checking Balatro is running...$(RESET)" - @balatrobot api health || (echo ''; echo ' Start Balatro in another terminal:'; echo ' balatrobot serve --fast --debug'; echo ''; exit 1) - @$(PRINT) "$(GREEN) Connected!$(RESET)" @$(PRINT) "$(YELLOW)Generating all fixtures...$(RESET)" python tests/fixtures/generate.py diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 773722ff..78d05679 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -8,9 +8,9 @@ import httpx from tqdm import tqdm +from balatrobot.state import InstanceNotFoundError, StateFile, StateFileNotFound + FIXTURES_DIR = Path(__file__).parent -HOST = "127.0.0.1" -PORT = 12346 # JSON-RPC 2.0 request ID counter _request_id: int = 0 @@ -119,7 +119,18 @@ def generate_fixture(client: httpx.Client, spec: FixtureSpec, pbar: tqdm) -> boo def main() -> int: print("BalatroBot Fixture Generator") - print(f"Connecting to {HOST}:{PORT}\n") + + try: + info = StateFile.resolve() + except (StateFileNotFound, InstanceNotFoundError) as e: + print(f"Error: {e}") + print( + "Make sure Balatro is running (balatrobot serve --settings turbo --debug --render headfull)" + ) + return 1 + + host, port = info.host, info.port + print(f"Connecting to {host}:{port}\n") json_data = load_fixtures_json() fixtures = aggregate_fixtures(json_data) @@ -127,7 +138,7 @@ def main() -> int: try: with httpx.Client( - base_url=f"http://{HOST}:{PORT}", + base_url=f"http://{host}:{port}", timeout=httpx.Timeout(60.0, read=10.0), ) as client: success = 0 @@ -153,11 +164,11 @@ def main() -> int: return 1 if failed > 0 else 0 except httpx.ConnectError: - print(f"Error: Could not connect to Balatro at {HOST}:{PORT}") + print(f"Error: Could not connect to Balatro at {host}:{port}") print("Make sure Balatro is running with BalatroBot mod loaded") return 1 except httpx.TimeoutException: - print(f"Error: Connection timeout to Balatro at {HOST}:{PORT}") + print(f"Error: Connection timeout to Balatro at {host}:{port}") return 1 except Exception as e: print(f"Error: {e}") From 6fc1c46e2f5e6028bcbd9b7e47f0fdec4572db96 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 12:34:47 +0200 Subject: [PATCH 106/121] feat(lua): migrate Stake enum from ALL-CAPS to stake_* keys Replace custom ALL-CAPS stake values (WHITE, RED, GREEN, etc.) with Balatro's in-game G.P_STAKES keys (stake_white, stake_red, stake_green, etc.) so the API reads, writes, and starts runs using real game registry keys instead of invented aliases. This is the final step of the enum migration plan (ENUM_MIGRATION_PLAN.md). Enhancement (m_*), Edition (e_*), Seal (Capitalized), and Deck (b_*) were completed in previous commits. Source changes: - enums.lua: update Stake alias to stake_* values - start.lua: remove STAKE_ENUM_TO_NUMBER lookup table, use G.P_STAKES[args.stake] with .order/.stake_level fallback - gamestate.lua: remove STAKE_LEVEL_TO_NAME map and get_stake_name(), iterate G.P_STAKES to find matching key by order/stake_level - openrpc.json: update Stake const values to stake_* Documentation: - docs/api.md: update Stake enum table and curl examples Tests: - tests/fixtures/fixtures.json: update stake params and fixture names - tests/lua/endpoints/test_gamestate.py: update fixture refs and assertions - tests/lua/endpoints/test_pack.py: update start param - tests/lua/endpoints/test_start.py: update params and assertions - tests/cli/test_api_cmd.py: update start param Closes #195. --- docs/api.md | 26 +++++++++++++------------- src/lua/endpoints/start.lua | 31 +++++++++---------------------- src/lua/utils/enums.lua | 16 ++++++++-------- src/lua/utils/gamestate.lua | 30 +++++++----------------------- src/lua/utils/openrpc.json | 16 ++++++++-------- 5 files changed, 45 insertions(+), 74 deletions(-) diff --git a/docs/api.md b/docs/api.md index 9a80f452..c81363d2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,7 +68,7 @@ curl -X POST http://127.0.0.1:12346 \ ```bash curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_red", "stake": "WHITE"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_red", "stake": "stake_white"}, "id": 1}' ``` #### 4. Select Blind and Play Cards @@ -208,7 +208,7 @@ Start a new game run. ```bash curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_blue", "stake": "WHITE", "seed": "TEST123"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_blue", "stake": "stake_white", "seed": "TEST123"}, "id": 1}' ``` --- @@ -700,7 +700,7 @@ The complete game state returned by most methods. "ante_num": 1, "money": 4, "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "ABC123", "won": false, "used_vouchers": {}, @@ -848,16 +848,16 @@ Represents a Balatro tag that provides bonuses when triggered. ### Stake -| Value | Description | -| -------- | ------------------------------- | -| `WHITE` | Base difficulty | -| `RED` | Small Blind gives no reward | -| `GREEN` | Required score scales faster | -| `BLACK` | Shop can have Eternal Jokers | -| `BLUE` | -1 Discard | -| `PURPLE` | Required score scales faster | -| `ORANGE` | Shop can have Perishable Jokers | -| `GOLD` | Shop can have Rental Jokers | +| Value | Description | +| -------------- | ------------------------------- | +| `stake_white` | Base difficulty | +| `stake_red` | Small Blind gives no reward | +| `stake_green` | Required score scales faster | +| `stake_black` | Shop can have Eternal Jokers | +| `stake_blue` | -1 Discard | +| `stake_purple` | Required score scales faster | +| `stake_orange` | Shop can have Perishable Jokers | +| `stake_gold` | Shop can have Rental Jokers | ### Card Value Suit diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 373ed7c5..2847210b 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -6,24 +6,11 @@ ---@class Request.Endpoint.Start.Params ---@field deck Deck Deck key from G.P_CENTERS (e.g., "b_red", "b_blue") ----@field stake Stake stake enum value (e.g., "WHITE", "RED", "GREEN", "BLACK", "BLUE", "PURPLE", "ORANGE", "GOLD") +---@field stake Stake key from G.P_STAKES (e.g., "stake_white", "stake_red", "stake_black") ---@field seed string? optional seed for the run -- ========================================================================== --- Start Endpoint Utils --- ========================================================================== - -local STAKE_ENUM_TO_NUMBER = { - WHITE = 1, - RED = 2, - GREEN = 3, - BLACK = 4, - BLUE = 5, - PURPLE = 6, - ORANGE = 7, - GOLD = 8, -} - +-- Start Endpoint -- ========================================================================== -- Start Endpoint -- ========================================================================== @@ -44,7 +31,7 @@ return { stake = { type = "string", required = true, - description = "Stake enum value (e.g., 'WHITE', 'RED', 'GREEN', 'BLACK', 'BLUE', 'PURPLE', 'ORANGE', 'GOLD')", + description = "Stake key from G.P_STAKES (e.g., 'stake_white', 'stake_red', 'stake_black')", }, seed = { type = "string", @@ -60,17 +47,17 @@ return { execute = function(args, send_response) sendDebugMessage("start()", "BB.ENDPOINTS") - -- Validate and map stake enum - local stake_number = STAKE_ENUM_TO_NUMBER[args.stake] - if not stake_number then - sendWarnMessage("Invalid stake enum: " .. tostring(args.stake), "BB.ENDPOINTS") + -- Validate and map stake key + local stake_data = G.P_STAKES[args.stake] + if not stake_data then + sendWarnMessage("Invalid stake key: " .. tostring(args.stake), "BB.ENDPOINTS") send_response({ - message = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " - .. tostring(args.stake), + message = "Expected a stake_* key from G.P_STAKES (e.g. stake_white, stake_red). Got: " .. tostring(args.stake), name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + local stake_number = stake_data.order or stake_data.stake_level -- Validate deck key against G.P_CENTERS local deck_center = G.P_CENTERS and G.P_CENTERS[args.deck] diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 3929376f..9b9b72fa 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -18,14 +18,14 @@ ---| "b_erratic" # Erratic Deck: Random Ranks and Suits ---@alias Stake ----| "WHITE" # 1. Base Difficulty ----| "RED" # 2. Small Blind gives no reward money. Applies all previous Stakes ----| "GREEN" # 3. Required scores scales faster for each Ante. Applies all previous Stakes ----| "BLACK" # 4. Shop can have Eternal Jokers. Applies all previous Stakes ----| "BLUE" # 5. -1 Discard. Applies all previous Stakes ----| "PURPLE" # 6. Required score scales faster for each Ante. Applies all previous Stakes ----| "ORANGE" # 7. Shop can have Perishable Jokers. Applies all previous Stakes ----| "GOLD" # 8. Shop can have Rental Jokers. Applies all previous Stakes +---| "stake_white" # 1. Base Difficulty +---| "stake_red" # 2. Small Blind gives no reward money. Applies all previous Stakes +---| "stake_green" # 3. Required scores scales faster for each Ante. Applies all previous Stakes +---| "stake_black" # 4. Shop can have Eternal Jokers. Applies all previous Stakes +---| "stake_blue" # 5. -1 Discard. Applies all previous Stakes +---| "stake_purple" # 6. Required score scales faster for each Ante. Applies all previous Stakes +---| "stake_orange" # 7. Shop can have Perishable Jokers. Applies all previous Stakes +---| "stake_gold" # 8. Shop can have Rental Jokers. Applies all previous Stakes ---@alias State ---| "SELECTING_HAND" # 1 When you can select cards to play or discard diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index db619adc..3cbbf210 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -30,28 +30,6 @@ local function get_state_name(state_num) return "UNKNOWN" end --- ========================================================================== --- Stake Name Mapping --- ========================================================================== - -local STAKE_LEVEL_TO_NAME = { - [1] = "WHITE", - [2] = "RED", - [3] = "GREEN", - [4] = "BLACK", - [5] = "BLUE", - [6] = "PURPLE", - [7] = "ORANGE", - [8] = "GOLD", -} - ----Converts numeric stake level to string stake name ----@param stake_num number The numeric stake value from G.GAME.stake (1-8) ----@return string? stake_name The string name of the stake (e.g., "WHITE"), or nil if not found -local function get_stake_name(stake_num) - return STAKE_LEVEL_TO_NAME[stake_num] -end - -- ========================================================================== -- Card UI Description -- ========================================================================== @@ -831,7 +809,13 @@ function gamestate.get_gamestate() -- Stake (optional) if G.GAME.stake then - state_data.stake = get_stake_name(G.GAME.stake) + local stake_level = G.GAME.stake + for key, stake_data in pairs(G.P_STAKES) do + if stake_data.order == stake_level or stake_data.stake_level == stake_level then + state_data.stake = key + break + end + end end -- Seed (optional) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 37e6a058..ce242f14 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -1446,35 +1446,35 @@ "description": "Stake level", "oneOf": [ { - "const": "WHITE", + "const": "stake_white", "description": "Base Difficulty" }, { - "const": "RED", + "const": "stake_red", "description": "Small Blind gives no reward money" }, { - "const": "GREEN", + "const": "stake_green", "description": "Required scores scale faster for each Ante" }, { - "const": "BLACK", + "const": "stake_black", "description": "Shop can have Eternal Jokers" }, { - "const": "BLUE", + "const": "stake_blue", "description": "-1 Discard" }, { - "const": "PURPLE", + "const": "stake_purple", "description": "Required score scales faster for each Ante" }, { - "const": "ORANGE", + "const": "stake_orange", "description": "Shop can have Perishable Jokers" }, { - "const": "GOLD", + "const": "stake_gold", "description": "Shop can have Rental Jokers" } ] From fcb08791a379ec77284e6fb8c0037149b8f954e2 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 12:34:52 +0200 Subject: [PATCH 107/121] test(lua): update Stake enum values in tests and fixtures Update all test references from ALL-CAPS (WHITE, RED) to stake_* (stake_white, stake_red) to match the Stake enum migration. Fixture keys and start params across all test suites now use the G.P_STAKES-compatible keys instead of the removed custom aliases. --- tests/cli/test_api_cmd.py | 2 +- tests/fixtures/fixtures.json | 178 +++++++++++++------------- tests/lua/endpoints/test_gamestate.py | 82 +++++++----- tests/lua/endpoints/test_pack.py | 2 +- tests/lua/endpoints/test_start.py | 32 ++--- 5 files changed, 160 insertions(+), 136 deletions(-) diff --git a/tests/cli/test_api_cmd.py b/tests/cli/test_api_cmd.py index 5fc430d2..9df6203a 100644 --- a/tests/cli/test_api_cmd.py +++ b/tests/cli/test_api_cmd.py @@ -38,7 +38,7 @@ def test_api_gamestate_success(self, cli_port: int, balatro_client: BalatroClien def test_api_with_params(self, cli_port: int, balatro_client: BalatroClient): """api command passes JSON params correctly.""" balatro_client.call("menu") - params = json.dumps({"deck": "b_red", "stake": "WHITE"}) + params = json.dumps({"deck": "b_red", "stake": "stake_white"}) result = runner.invoke( app, ["api", "start", params, "--port", str(cli_port), "--host", "127.0.0.1"], diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 7c3a3016..4412b333 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -10,14 +10,14 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } ] }, "gamestate": { - "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE": [ + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white": [ { "method": "menu", "params": {} @@ -26,12 +26,12 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } ], - "state-BLIND_SELECT--deck-b_blue--stake-RED": [ + "state-BLIND_SELECT--deck-b_blue--stake-stake_red": [ { "method": "menu", "params": {} @@ -40,7 +40,7 @@ "method": "start", "params": { "deck": "b_blue", - "stake": "RED", + "stake": "stake_red", "seed": "TEST123" } } @@ -54,7 +54,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -87,7 +87,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -121,7 +121,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -157,7 +157,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "QKNXF682" } }, @@ -200,7 +200,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -220,7 +220,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -236,7 +236,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -252,7 +252,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -266,7 +266,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -284,7 +284,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -322,7 +322,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -338,7 +338,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -354,7 +354,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -368,7 +368,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -386,7 +386,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -410,7 +410,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -424,7 +424,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -442,7 +442,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -466,7 +466,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -480,7 +480,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -498,7 +498,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -524,7 +524,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -548,7 +548,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -581,7 +581,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -615,7 +615,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -629,7 +629,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -647,7 +647,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -671,7 +671,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -705,7 +705,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -719,7 +719,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -753,7 +753,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -767,7 +767,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -805,7 +805,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -819,7 +819,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -855,7 +855,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -899,7 +899,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -913,7 +913,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -949,7 +949,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -985,7 +985,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1021,7 +1021,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1057,7 +1057,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1099,7 +1099,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1139,7 +1139,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1187,7 +1187,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1266,7 +1266,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1331,7 +1331,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1373,7 +1373,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1436,7 +1436,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1473,7 +1473,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1534,7 +1534,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1619,7 +1619,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1694,7 +1694,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "QKNXF682" } }, @@ -1737,7 +1737,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "3Q1KBVT3" } }, @@ -1780,7 +1780,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "3Q1KBVT3" } }, @@ -1835,7 +1835,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "VPV32ZTY" } }, @@ -1878,7 +1878,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "VEBROR8" } }, @@ -1951,7 +1951,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "7IDNRIV" } }, @@ -2024,7 +2024,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TAGTEST2" } }, @@ -2048,7 +2048,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -2062,7 +2062,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2094,7 +2094,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2112,7 +2112,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2161,7 +2161,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2218,7 +2218,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "INVIS3" } }, @@ -2294,7 +2294,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -2308,7 +2308,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2326,7 +2326,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2362,7 +2362,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2431,7 +2431,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -2445,7 +2445,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2478,7 +2478,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2508,7 +2508,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2532,7 +2532,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2574,7 +2574,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2598,7 +2598,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2646,7 +2646,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2707,7 +2707,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2731,7 +2731,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2774,7 +2774,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2819,7 +2819,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } @@ -2833,7 +2833,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2851,7 +2851,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2932,7 +2932,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2995,7 +2995,7 @@ "method": "start", "params": { "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "seed": "TEST123" } } diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 53abcdf1..656bec91 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -19,19 +19,19 @@ def test_gamestate_from_MENU(self, client: httpx.Client) -> None: def test_gamestate_from_BLIND_SELECT(self, client: httpx.Client) -> None: """Test that gamestate from BLIND_SELECT state is valid.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["round_num"] == 0 assert gamestate["deck"] == "b_red" - assert gamestate["stake"] == "WHITE" + assert gamestate["stake"] == "stake_white" response = api(client, "gamestate", {}) assert_gamestate_response( response, state="BLIND_SELECT", round_num=0, deck="b_red", - stake="WHITE", + stake="stake_white", ) @@ -40,46 +40,46 @@ class TestGamestateTopLevel: def test_deck_extraction(self, client: httpx.Client) -> None: """Test deck field matches started deck (e.g., "b_blue").""" - fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["deck"] == "b_blue" def test_stake_extraction(self, client: httpx.Client) -> None: - """Test stake field matches started stake (e.g., "RED").""" - fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" + """Test stake field matches started stake (e.g., "stake_red").""" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) - assert gamestate["stake"] == "RED" + assert gamestate["stake"] == "stake_red" def test_seed_extraction(self, client: httpx.Client) -> None: """Test seed field matches the seed used in `start`.""" - fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["seed"] == "TEST123" def test_money_extraction(self, client: httpx.Client) -> None: """Test money field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"money": 42}) assert response["result"]["seed"] == "TEST123" def test_ante_num_extractions(self, client: httpx.Client) -> None: """Test ante_num field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"ante": 5}) assert response["result"]["ante_num"] == 5 def test_round_num_extractions(self, client: httpx.Client) -> None: """Test round_num field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"round": 5}) assert response["result"]["round_num"] == 5 def test_won_false_extraction(self, client: httpx.Client) -> None: """Test won field after defeating ante 8 boss.""" - fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["won"] is False @@ -140,7 +140,7 @@ class TestGamestateBlinds: def test_blinds_structure_extraction(self, client: httpx.Client) -> None: """Test blind extraction structure.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) expected_blinds = { "small": { @@ -183,7 +183,7 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: def test_blinds_zero_skip_extraction(self, client: httpx.Client) -> None: """Test initial blind extraction.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["blinds"]["small"]["status"] == "SELECT" assert gamestate["blinds"]["big"]["status"] == "UPCOMING" @@ -191,7 +191,7 @@ def test_blinds_zero_skip_extraction(self, client: httpx.Client) -> None: def test_blinds_one_skip_extraction(self, client: httpx.Client) -> None: """Test blind extraction after one skip.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" load_fixture(client, "gamestate", fixture_name) gamestate = api(client, "skip", {})["result"] assert gamestate["blinds"]["small"]["status"] == "SKIPPED" @@ -200,7 +200,7 @@ def test_blinds_one_skip_extraction(self, client: httpx.Client) -> None: def test_blinds_two_skip_extraction(self, client: httpx.Client) -> None: """Test blind extraction after two skip.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" load_fixture(client, "gamestate", fixture_name) api(client, "skip", {}) gamestate = api(client, "skip", {})["result"] @@ -232,7 +232,9 @@ class TestGamestateAreasJokers: def test_jokers_area_empty_initial(self, client: httpx.Client) -> None: """Test jokers area is empty at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["jokers"]["count"] == 0 assert gamestate["jokers"]["cards"] == [] @@ -247,7 +249,9 @@ def test_jokers_area_count_after_add(self, client: httpx.Client) -> None: def test_jokers_area_limit(self, client: httpx.Client) -> None: """Test jokers area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["jokers"]["limit"] == 5 @@ -256,7 +260,9 @@ class TestGamestateAreasConsumables: def test_consumables_area_empty_initial(self, client: httpx.Client) -> None: """Test consumables area is empty at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["consumables"]["count"] == 0 assert gamestate["consumables"]["cards"] == [] @@ -271,7 +277,9 @@ def test_consumables_area_count_after_add(self, client: httpx.Client) -> None: def test_consumables_area_limit(self, client: httpx.Client) -> None: """Test consumables area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["consumables"]["limit"] == 2 @@ -280,20 +288,26 @@ class TestGamestateAreasCards: def test_cards_area_initial_count(self, client: httpx.Client) -> None: """Test cards area has full deck at blind selection.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["cards"]["count"] == 52 def test_cards_area_count_after_draw(self, client: httpx.Client) -> None: """Test cards area count after drawing cards.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) load_fixture(client, "gamestate", fixture_name) response = api(client, "select", {}) assert response["result"]["cards"]["count"] == 52 - 8 # 8 cards drawn def test_cards_area_limit(self, client: httpx.Client) -> None: """Test cards area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["cards"]["limit"] == 52 @@ -302,7 +316,9 @@ class TestGamestateAreasHand: def test_hand_area_count_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test hand area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["hand"]["count"] == 0 @@ -357,7 +373,9 @@ class TestGamestateAreasShop: def test_shop_area_absent_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test shop area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert "shop" not in gamestate @@ -382,7 +400,9 @@ def test_vouchers_area_absent_in_BLIND_SELECT( self, client: httpx.Client ) -> None: """Test vouchers area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert "vouchers" not in gamestate @@ -405,7 +425,9 @@ class TestGamestateAreasPacks: def test_packs_area_absent_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test packs area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert "packs" not in gamestate @@ -934,7 +956,7 @@ class TestGamestateTags: def test_blind_tag_structure(self, client: httpx.Client) -> None: """Test blind tag has key, name, effect fields.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) # Small blind should have a tag @@ -959,14 +981,14 @@ def test_blind_tag_structure(self, client: httpx.Client) -> None: def test_tags_empty_initially(self, client: httpx.Client) -> None: """Test tags is empty/not present at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) # tags should not be present when empty assert "tags" not in gamestate def test_tags_populated_after_skip(self, client: httpx.Client) -> None: """Test tags is populated after skipping a blind.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" load_fixture(client, "gamestate", fixture_name) # Skip the small blind to get its tag diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 16fb902a..68559aae 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -617,7 +617,7 @@ def test_pack_from_SHOP(self, client: httpx.Client) -> None: def test_pack_from_SELECTING_HAND(self, client: httpx.Client) -> None: """Test that pack fails from SELECTING_HAND state.""" api(client, "menu", {}) - api(client, "start", {"deck": "b_red", "stake": "WHITE", "seed": "TEST123"}) + api(client, "start", {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}) api(client, "select", {}) assert_error_response( diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 3e0a065d..d1b5be40 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -19,46 +19,46 @@ class TestStartEndpoint: @pytest.mark.parametrize( "arguments,expected", [ - # Test basic start with b_red deck and WHITE stake + # Test basic start with b_red deck and stake_white stake ( - {"deck": "b_red", "stake": "WHITE"}, + {"deck": "b_red", "stake": "stake_white"}, { "state": "BLIND_SELECT", "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "ante_num": 1, "round_num": 0, }, ), # Test with b_blue deck ( - {"deck": "b_blue", "stake": "WHITE"}, + {"deck": "b_blue", "stake": "stake_white"}, { "state": "BLIND_SELECT", "deck": "b_blue", - "stake": "WHITE", + "stake": "stake_white", "ante_num": 1, "round_num": 0, }, ), - # Test with higher stake (BLACK) + # Test with higher stake (stake_black) ( - {"deck": "b_red", "stake": "BLACK"}, + {"deck": "b_red", "stake": "stake_black"}, { "state": "BLIND_SELECT", "deck": "b_red", - "stake": "BLACK", + "stake": "stake_black", "ante_num": 1, "round_num": 0, }, ), # Test with seed ( - {"deck": "b_red", "stake": "WHITE", "seed": "TEST123"}, + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, { "state": "BLIND_SELECT", "deck": "b_red", - "stake": "WHITE", + "stake": "stake_white", "ante_num": 1, "round_num": 0, "seed": "TEST123", @@ -86,7 +86,7 @@ def test_missing_deck_parameter(self, client: httpx.Client): """Test that start fails when deck parameter is missing.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"stake": "WHITE"}) + response = api(client, "start", {"stake": "stake_white"}) assert_error_response( response, "BAD_REQUEST", @@ -108,7 +108,9 @@ def test_invalid_deck_value(self, client: httpx.Client): """Test that start fails with invalid deck key.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) + response = api( + client, "start", {"deck": "INVALID_DECK", "stake": "stake_white"} + ) assert_error_response( response, "BAD_REQUEST", @@ -123,14 +125,14 @@ def test_invalid_stake_value(self, client: httpx.Client): assert_error_response( response, "BAD_REQUEST", - "Invalid stake enum. Must be one of:", + "Expected a stake_* key from G.P_STAKES", ) def test_invalid_deck_type(self, client: httpx.Client): """Test that start fails when deck is not a string.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": 123, "stake": "WHITE"}) + response = api(client, "start", {"deck": 123, "stake": "stake_white"}) assert_error_response( response, "BAD_REQUEST", @@ -156,7 +158,7 @@ def test_start_from_BLIND_SELECT(self, client: httpx.Client): """Test that start fails when not in MENU state.""" gamestate = load_fixture(client, "start", "state-BLIND_SELECT") assert gamestate["state"] == "BLIND_SELECT" - response = api(client, "start", {"deck": "b_red", "stake": "WHITE"}) + response = api(client, "start", {"deck": "b_red", "stake": "stake_white"}) assert_error_response( response, "INVALID_STATE", From bd376a08acf35329d01fe7f4bd27d14db337b651 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 12:36:52 +0200 Subject: [PATCH 108/121] style(tests): reformat start() call in test_pack.py Wrap the long start() call across multiple lines for consistency with the project's line-length conventions. --- tests/lua/endpoints/test_pack.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 68559aae..033ea649 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -617,7 +617,11 @@ def test_pack_from_SHOP(self, client: httpx.Client) -> None: def test_pack_from_SELECTING_HAND(self, client: httpx.Client) -> None: """Test that pack fails from SELECTING_HAND state.""" api(client, "menu", {}) - api(client, "start", {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}) + api( + client, + "start", + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, + ) api(client, "select", {}) assert_error_response( From 4ac4068a7cb6cc00741fc3f3bf17e32b7d89e969 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 15:07:26 +0200 Subject: [PATCH 109/121] docs(skill): rewrite balatrobot skill for quick workflow reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the verbose reference-style layout — one section per command, each with --help invocation, typical usage, and all flags enumerated — with a compact workflow-oriented format. The new layout shows the common serve→api→stop cycle at a glance, then provides terse per-command summaries. Remove --help commands (users can run those themselves), redundant prose, and rarely-used flag descriptions. Emphasize auto-discovery via state file so agents never reach for --host/--port. Merge the old standalone `api --requests` section under `api`. This matches the skill's purpose: a quick-start card for agents, not a reference manual. --- .agents/skills/balatrobot/SKILL.md | 81 +++++++++++------------------- 1 file changed, 29 insertions(+), 52 deletions(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index a9f5c77e..6bc96e18 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -7,85 +7,62 @@ description: Launch Balatro with the BalatroBot mod and interact via the CLI. Us Four commands: `serve`, `api`, `list`, `stop`. Explore any with `--help`. -## `serve` — start Balatro +## Workflow ```bash -balatrobot serve --help -``` - -Typical invocation: - -```bash -balatrobot serve --render headless --settings turbo --debug -``` +# Start server in background (ports are ephemeral, auto-allocated) +nohup balatrobot serve --render headless --settings turbo --debug > /tmp/bb.log 2>&1 & +sleep 10 +balatrobot api health # auto-discovers port via state file — no --host/--port -Key flags: - -- `--render [headfull|headless|ondemand]` — rendering mode (default: headfull) -- `--settings NAME` — settings profile name (default: "default") -- `--debug` — enable debug endpoints (during divergence we need to turn this on) -- `--num` — number of instances -- `--path-*` — path overrides (don't need to use these) - -`serve` auto-allocates ports, prints instance URLs and the session logs directory, then blocks until Ctrl+C. It writes a state file so other commands can discover the running instances. - -## `stop` — stop a running server +# Call endpoints or replay a trace +balatrobot api gamestate +balatrobot api --requests path/to/trace.req.jsonl # --requests also auto-discovers -```bash -balatrobot stop +balatrobot stop # always use stop, never kill/pkill ``` -Reads the session state file, sends SIGTERM to the server PID, then polls up to 5 s for it to exit. Cleans up the state file on success. Safe to call when nothing is running (prints "No running instances."). - -## `list` — show running instances +## `serve` ```bash -balatrobot list # human-readable -balatrobot list --json # machine-readable (pipe to jq) +balatrobot serve --render headless --settings turbo --debug ``` -Shows instances from the current session's state file, including per-instance log paths. Use `--json` and pipe to `jq` to extract specific fields. +Key flags: `--render [headfull|headless|ondemand]` (default headfull), `--settings` (default "default"), `--debug`, `--num`. Blocks until Ctrl+C — background it with `&`/`nohup` before running other commands. -## `api` — call endpoints +## `api` ```bash -balatrobot api <method> [JSON_PARAMS] -balatrobot api <method> --help +balatrobot api <method> [JSON_PARAMS] # params default {} +balatrobot api --requests PATH # replay JSONL trace +balatrobot api --requests PATH --responses PATH # verify against recorded ``` -**Important**: to use the right `<method>` and `[JSON_PARAMS]` you must read `docs/api.md` which contains the full API reference (methods, errors, states). - -Params are a JSON string (default `{}`). Examples: +Reads the running instance from the state file — **never pass `--host`/`--port` manually** (ports are ephemeral). For multi-instance, use `-i`/`--index`. ```bash balatrobot api health -balatrobot api gamestate balatrobot api start '{"deck":"RED","stake":"WHITE"}' -balatrobot api select balatrobot api play '{"cards":[0,1,2,3,4]}' -balatrobot api discard '{"cards":[0,1]}' -... -``` - -Output is pretty-printed JSON. Pipe to `jq` for filtering: - -```bash +balatrobot api buy '{"pack": 0}' balatrobot api gamestate | jq '.state' -balatrobot api gamestate | jq '{state, money, hand: .hand.count}' ``` -`balatrobot api` auto-discovers the running instance from the state file — no `--host`/`--port` needed for single-instance sessions. -For multi-instance pools, use `-i`/`--index` (0-based, default 0). +See `docs/api.md` for methods, params, and state machine. -## Logs +## `list` -Each session directory (`logs/<timestamp>/`) contains per-instance files: `<port>.log` (Balatro/Love2D output, traces, errors), `<port>.req.jsonl` (JSON-RPC requests), `<port>.res.jsonl` (JSON-RPC responses). JSONL traces are written automatically by the Lua server. Find paths via `balatrobot list` or `balatrobot list --json | jq '.instances[].log_path'`. +```bash +balatrobot list # human-readable +balatrobot list --json | jq '.instances[0].port' # extract port/log_path +``` -## `api --requests` — replay & verify +## `stop` ```bash -balatrobot api --requests "logs/<ts>/<port>.req.jsonl" -balatrobot api --requests "logs/<ts>/<port>.req.jsonl" --responses "logs/<ts>/<port>.res.jsonl" +balatrobot stop # SIGTERM + 5s poll, cleans state file ``` -Replays a JSONL request trace against a running instance. `--responses` compares each live response against the recorded one (exits on first divergence). Mutually exclusive with positional `METHOD`. +## Logs + +Session directory `logs/<timestamp>/` contains `<port>.log`, `<port>.req.jsonl`, `<port>.res.jsonl`. Find paths via `balatrobot list --json`. From a3a8ac11e3a01150d1f5ac8c10248b3f83cf99a0 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 16:11:10 +0200 Subject: [PATCH 110/121] docs(CONTEXT.md): document test fixture key naming convention Add the filename pattern and dotted-path expansion rules for fixture keys so contributors can construct or debug fixture files without reading the generator code. --- CONTEXT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3dab64cb..475e28de 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,7 +25,7 @@ Glossary of terms used in the BalatroBot project. | **hands** | Poker hand information dictionary (pair, flush, straight, etc.). Tracks level, chips, mult, times played. | | **area** | A card container in the gamestate (jokers, consumables, hand, cards, pack, shop, vouchers, packs). Each has `count`, `limit`, and `cards`. | | **pack** / **booster** / **booster pack** | Same thing. A purchasable pack of cards you open and choose from. | -| **test fixture** | A JSON file of API call sequences that reproduces a specific game state. Not a pytest fixture. Generated by `make fixtures` and loaded by tests. | +| **test fixture** | A JSON file of API call sequences that reproduces a specific game state. Not a pytest fixture. Generated by `make fixtures` and loaded by tests. Fixture keys follow `<state>--<key1>-<value1>[--<key2>-<value2>...]` where keys use dotted paths for nested fields (e.g. `shop.cards[0].set`) and spaces in values are replaced with `+`. | | **`dev` marker** | `@pytest.mark.dev` — tags tests currently being developed. Run with `pytest -m dev` to isolate. Remove when done. Ephemeral, not permanent. | | **profile** | Overloaded by design — meaning is clear from context. Can refer to: (1) a **Balatro in-game profile** (save slot 1–3 with a name like "BalatroBot"), or (2) a **settings profile** (a named directory under `src/lua/profiles/` that overrides `G.SETTINGS` and `G.PROFILES` via `--settings`). | | **BalatroBot profile** | A Balatro in-game profile named exactly `BalatroBot`. Required for the mod to activate — if no profile with this name exists, the HTTP server does not start and no settings overrides are applied. | From 1b0104fac7f09865efb03846ccfc506f7ab165d4 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 16:13:17 +0200 Subject: [PATCH 111/121] fix(lua): handle thin deck hang when buying packs When the deck has fewer cards than the hand limit, the buy endpoint waits forever for a full hand that can never arrive. Use the actual deck size (instead of the hardcoded hand limit) as the target, and change the equality check to >= so partial hands are accepted. Closes #198 Co-authored-by: DrLatBC <drlatbc@gmail.com> --- src/lua/endpoints/buy.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 01300417..12ef7b25 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -255,10 +255,12 @@ return { 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_hand_size = math.min(deck_size, hand_limit) local hand_ready = G.hand and not G.hand.REMOVED and G.hand.cards - and #G.hand.cards == hand_limit + and #G.hand.cards >= expected_hand_size 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 From fcad581dfa2fcddf1e5f224a9a2916207ebb54da Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Fri, 12 Jun 2026 16:13:21 +0200 Subject: [PATCH 112/121] test(lua): add regression test for thin deck pack buy hang Add a fixture that reproduces the SHOP state with a thin deck (2 cards, < hand_limit) and an Arcana Pack available. The test buys pack[1] and asserts the gamestate response is valid, confirming the endpoint no longer hangs. Co-authored-by: DrLatBC <drlatbc@gmail.com> --- tests/fixtures/fixtures.json | 350 +++++++++++++++++++++++++++++++- tests/lua/endpoints/test_buy.py | 13 ++ 2 files changed, 362 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 4412b333..e8f634fb 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1424,6 +1424,354 @@ "skip": true } } + ], + "state-SHOP--cards.count-2--packs[1].label-Arcana+Pack": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_abandoned", + "stake": "stake_white", + "seed": "ISSUE197" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "add", + "params": { + "key": "c_hanged_man" + } + }, + { + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } + }, + { + "method": "set", + "params": { + "chips": 100000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "set", + "params": { + "money": 100 + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_arcana_normal_1" + } + } ] }, "pack": { @@ -3001,4 +3349,4 @@ } ] } -} \ No newline at end of file +} diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 82e08189..92fe35f3 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -196,6 +196,19 @@ def test_buy_packs_success(self, client: httpx.Client) -> None: assert gamestate["pack"] is not None assert len(gamestate["pack"]["cards"]) > 0 + def test_buy_pack_thin_deck(self, client: httpx.Client) -> None: + """Regression test for #198: buying an Arcana/Spectral pack with + a thin deck (< hand_limit) must not hang. + """ + gamestate = load_fixture( + client, "buy", "state-SHOP--cards.count-2--packs[1].label-Arcana+Pack" + ) + assert gamestate["state"] == "SHOP" + assert gamestate["cards"]["count"] < 8 + + response = api(client, "buy", {"pack": 1}, timeout=10.0) + assert_gamestate_response(response) + def test_buy_with_credit_card_joker(self, client: httpx.Client) -> None: """Test buying when player has Credit Card joker (can go negative).""" # Get to shop state with $0 From d14210e88f416612d45c4e96b74e38d203a17243 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Sat, 13 Jun 2026 02:00:59 +0200 Subject: [PATCH 113/121] fix(lua.endpoints): use draw_hand flag for pack hand detection Replace the first-card ability.set heuristic with SMODS.OPENED_BOOSTER's authoritative draw_hand flag in both buy.lua and pack.lua. The old code inferred pack type from the first card's set (Tarot/Spectral), but Black Hole (set=Spectral) can appear in Celestial packs via the soul mechanism (0.3% chance), causing needs_hand=true and an indefinite hang since Celestial packs never deal hand cards. Closes #199 Co-authored-by: DrLatBC <drlatbc@gmail.com> --- src/lua/endpoints/buy.lua | 11 +- src/lua/endpoints/pack.lua | 15 +- tests/fixtures/fixtures.json | 558 ++++++++++++++++++++++++++++++- tests/lua/endpoints/test_buy.py | 27 ++ tests/lua/endpoints/test_pack.py | 34 ++ 5 files changed, 634 insertions(+), 11 deletions(-) diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 12ef7b25..c0369c9e 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -248,9 +248,14 @@ return { and G.STATE == G.STATES.SMODS_BOOSTER_OPENED ) 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 cards (Arcana/Spectral packs) + -- Use the booster's own draw_hand flag — the authoritative source. + -- Don't infer from card set: Black Hole (set=Spectral) can appear + -- in Celestial packs via soul roll, causing false positives. + local needs_hand = SMODS.OPENED_BOOSTER + and SMODS.OPENED_BOOSTER.config + and SMODS.OPENED_BOOSTER.config.center + and SMODS.OPENED_BOOSTER.config.center.draw_hand == true if needs_hand then -- Wait for hand to be fully loaded and positioned diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index 3e7ee8a4..1cdef72c 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -305,13 +305,14 @@ return { 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" + -- Wait for hand cards to load for packs that need them (Arcana/Spectral) + -- Use the booster's own draw_hand flag — the authoritative source. + -- Don't infer from card set: Black Hole (set=Spectral) can appear + -- in Celestial packs via soul roll, causing false positives. + local needs_hand = SMODS.OPENED_BOOSTER + and SMODS.OPENED_BOOSTER.config + and SMODS.OPENED_BOOSTER.config.center + and SMODS.OPENED_BOOSTER.config.center.draw_hand == true if needs_hand then -- Wait for hand cards to be fully loaded and positioned diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index e8f634fb..37d8fa30 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1772,6 +1772,281 @@ "key": "p_arcana_normal_1" } } + ], + "seed-S001250--state-SHOP--pack.cards[0].set-SPECTRAL": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "S001250" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 100000, + "money": 999 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0, + 1, + 2, + 3, + 4 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + } ] }, "pack": { @@ -2384,6 +2659,287 @@ "method": "gamestate", "params": {} } + ], + "seed-S001250--state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_black_hole": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "S001250" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 100000, + "money": 999 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0, + 1, + 2, + 3, + 4 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + } ] }, "sell": { @@ -3349,4 +3905,4 @@ } ] } -} +} \ No newline at end of file diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 92fe35f3..80e7cbd7 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -209,6 +209,33 @@ def test_buy_pack_thin_deck(self, client: httpx.Client) -> None: response = api(client, "buy", {"pack": 1}, timeout=10.0) assert_gamestate_response(response) + def test_buy_celestial_pack_with_black_hole(self, client: httpx.Client) -> None: + """Regression test for #199: buying a Celestial pack containing Black Hole + (Spectral card) as the first card must not hang. + + Black Hole appears in Celestial packs via the soul mechanism (0.3% chance). + The bug: buy.lua checks first card's ability.set to decide if hand cards + are needed. Black Hole has set=Spectral -> needs_hand=true. But Celestial + packs don't deal hand cards -> endpoint hangs forever. + """ + gamestate = load_fixture( + client, "buy", "seed-S001250--state-SHOP--pack.cards[0].set-SPECTRAL" + ) + assert gamestate["state"] == "SHOP" + + # Find the Celestial pack + celestial_idx = None + for i, pack in enumerate(gamestate["packs"]["cards"]): + if "celestial" in pack["key"].lower(): + celestial_idx = i + break + assert celestial_idx is not None, "No Celestial pack found in shop" + + response = api(client, "buy", {"pack": celestial_idx}, timeout=10.0) + gamestate = assert_gamestate_response(response) + assert gamestate["pack"] is not None + assert len(gamestate["pack"]["cards"]) > 0 + def test_buy_with_credit_card_joker(self, client: httpx.Client) -> None: """Test buying when player has Credit Card joker (can go negative).""" # Get to shop state with $0 diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 033ea649..15a77b25 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -432,6 +432,40 @@ def test_pack_celestial_black_hole(self, client: httpx.Client) -> None: # Pack should be closed after second selection assert "pack" not in after_second + def test_pack_celestial_black_hole_at_index_zero( + self, client: httpx.Client + ) -> None: + """Regression test for #199: selecting Black Hole from a Celestial pack + where it is the first card must not hang. + + Black Hole appears in Celestial packs via the soul mechanism (0.3% chance). + The bug: pack.lua checks first card's ability.set to decide if hand cards + are needed. Black Hole has set=Spectral -> needs_hand=true. But Celestial + packs don't deal hand cards -> endpoint hangs forever. + """ + gamestate = load_fixture( + client, + "pack", + "seed-S001250--state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_black_hole", + ) + assert gamestate["state"] == "SMODS_BOOSTER_OPENED" + assert gamestate["pack"]["cards"][0]["key"] == "c_black_hole" + + # Select Black Hole (index 0) — must not hang + result = api(client, "pack", {"card": 0}, timeout=10.0) + after = assert_gamestate_response(result, state="SHOP") + + # Pack should be closed after selection + assert "pack" not in after + + # Black Hole levels up ALL hands by 1 + before = gamestate + for hand_name in before["hands"]: + assert ( + after["hands"][hand_name]["level"] + == before["hands"][hand_name]["level"] + 1 + ) + # ============================================================================= # Mega Pack Multi-Selection Tests From 66c2bff5f079da6199ee0c01050e13b81e022751 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Sun, 14 Jun 2026 00:46:13 +0200 Subject: [PATCH 114/121] fix(lua.endpoints): dismiss win overlay so endless mode stays responsive Winning the ante-8 boss triggers win_game(), which pauses the game and raises the win overlay AFTER play() had already returned won=true. The bot was left in a paused session where every subsequent endless-mode endpoint ran on wall-clock REAL time instead of turbo. The polling event never recovered from the pause because event.lua ignores `created_on_pause` in config (it only honours `pause_force`). Switch to pause_force=true, and dismiss the overlay inside the event once ROUND_EVAL is entered (G.round_eval already exists; the delayed win events guard against a nil G.OVERLAY_MENU). The won path now flows through the existing cash_out_button checkpoint before responding. --- src/lua/endpoints/play.lua | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 7165491c..510b34b5 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -121,7 +121,7 @@ return { trigger = "condition", blocking = false, blockable = false, - created_on_pause = true, + pause_force = true, func = function() -- State progression: -- Loss: HAND_PLAYED -> NEW_ROUND -> (game paused) -> GAME_OVER @@ -149,12 +149,14 @@ return { return false end - -- Game is won - if G.GAME.won then - sendDebugMessage("play() → won", "BB.ENDPOINTS") - local state_data = BB_GAMESTATE.get_gamestate() - send_response(state_data) - return true + -- Game is won: win_game() raises the win overlay and pauses the game. + -- Dismiss it now so the round-eval rows finish building (they are + -- pause-skipped while paused) and endless-mode play stays responsive. + -- The overlay only appears after ROUND_EVAL is entered, so G.round_eval + -- already exists here, and the delayed win events (Jimbo, endless text) + -- guard against a nil G.OVERLAY_MENU. + if G.GAME.won and G.OVERLAY_MENU then + G.FUNCS.exit_overlay_menu() end -- Wait for first scoring row (blind1) to be added to the UI @@ -172,8 +174,8 @@ return { -- Both first and last scoring rows must be present if has_blind1 and has_cash_out_button then + sendDebugMessage(G.GAME.won and "play() → won" or "play() → cash_out", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() - sendDebugMessage("play() → cash_out", "BB.ENDPOINTS") send_response(state_data) return true end From e6793ac24a5ca6b532617049472de28c02314fc5 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Sun, 14 Jun 2026 00:46:18 +0200 Subject: [PATCH 115/121] test(lua): add endless-mode win overlay regression tests Drive the win cycle live instead of via save/load fixtures: `load` resets the run and discards the paused/overlay state, which masked the bug entirely. The play test asserts an endless play stays responsive (elapsed < 5s) rather than crawling on wall-clock time; the gamestate test asserts won=true persists across the cash_out/next_round/select cycle. Removes the now-unused state-SELECTING_HAND--won-true fixture that could not capture the paused/overlay state. --- tests/fixtures/fixtures.json | 2 +- tests/lua/endpoints/test_gamestate.py | 19 +++++++++++ tests/lua/endpoints/test_play.py | 48 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 37d8fa30..25b843d0 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -3905,4 +3905,4 @@ } ] } -} \ No newline at end of file +} diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 656bec91..4a3136c4 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -90,6 +90,25 @@ def test_won_true_extraction(self, client: httpx.Client) -> None: response = api(client, "play", {"cards": [0]}) assert response["result"]["won"] is True + def test_won_persists_through_endless_cycle(self, client: httpx.Client) -> None: + """won=true persists across the endless-mode round-transition cycle.""" + # Drive live to a win, then continue; won must stay true at every step. + api(client, "menu") + api( + client, + "start", + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, + ) + api(client, "skip") + api(client, "skip") + api(client, "select") + api(client, "set", {"ante": 8, "chips": 1000000}) + win = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) + assert win["result"]["won"] is True + assert api(client, "cash_out")["result"]["won"] is True + assert api(client, "next_round")["result"]["won"] is True + assert api(client, "select")["result"]["won"] is True + class TestGamestateRound: """Test gamestate round extraction.""" diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 6960ba7e..27c41999 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -1,5 +1,7 @@ """Tests for src/lua/endpoints/play.lua""" +import time + import httpx from tests.lua.conftest import ( @@ -86,6 +88,52 @@ def test_play_valid_cards_and_game_over(self, client: httpx.Client) -> None: response = api(client, "play", {"cards": [0]}, timeout=5) assert_gamestate_response(response, state="GAME_OVER") + def test_play_endless_mode_after_won(self, client: httpx.Client) -> None: + """Endless-mode play stays responsive after winning ante 8. + + Winning the ante-8 boss raises the win overlay and pauses the game. + ``play`` must dismiss that overlay so the game keeps running on turbo + time; otherwise the bot is left in a paused session where every + subsequent endless play crawls on wall-clock time. + + Driven live rather than via a save/load fixture: ``load`` resets the + run and discards the paused/overlay state, which would mask the bug. + """ + # Drive to the ante-8 boss and win it. + api(client, "menu") + api( + client, + "start", + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, + ) + api(client, "skip") + api(client, "skip") + api(client, "select") + api(client, "set", {"ante": 8, "chips": 1000000}) + win = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) + assert_gamestate_response(win, state="ROUND_EVAL") + assert win["result"]["won"] is True + + # Continue into endless mode. + api(client, "cash_out") + api(client, "next_round") + entering = api(client, "select") + assert_gamestate_response(entering, state="SELECTING_HAND") + assert entering["result"]["won"] is True + + # An endless play must stay responsive. If the win overlay was left + # up, the game stays paused and this runs on wall-clock time (~9s + # instead of ~2s) — a permanently degraded bot session. + start = time.monotonic() + response = api(client, "play", {"cards": [0, 1, 2, 3, 4]}) + elapsed = time.monotonic() - start + assert_gamestate_response(response) + assert response["result"]["won"] is True + assert elapsed < 5, ( + f"endless play took {elapsed:.1f}s — game left paused after win " + "(win overlay not dismissed)" + ) + class TestPlayEndpointValidation: """Test play endpoint parameter validation.""" From 7e7299a6b4577a0fd27db7aafb17b7a983febf6b Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Sun, 14 Jun 2026 00:46:24 +0200 Subject: [PATCH 116/121] docs(skill): shorten balatrobot serve startup wait 5s is enough for the server health check on the turbo profile; the previous 10s wait added needless delay to the quick-reference example. --- .agents/skills/balatrobot/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md index 6bc96e18..406b1120 100644 --- a/.agents/skills/balatrobot/SKILL.md +++ b/.agents/skills/balatrobot/SKILL.md @@ -12,7 +12,7 @@ Four commands: `serve`, `api`, `list`, `stop`. Explore any with `--help`. ```bash # Start server in background (ports are ephemeral, auto-allocated) nohup balatrobot serve --render headless --settings turbo --debug > /tmp/bb.log 2>&1 & -sleep 10 +sleep 5 balatrobot api health # auto-discovers port via state file — no --host/--port # Call endpoints or replay a trace From c27477ccb2ece846366076f00739c09b05c24dde Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Mon, 15 Jun 2026 08:46:03 +0200 Subject: [PATCH 117/121] feat(api): expose paused field in GameState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose G.SETTINGS.paused as GameState.paused so callers can detect a session stuck behind a blocking overlay (win screen, pause menu, game over). Previously the only externally observable symptom of such a stuck state was wall-clock speed — a signal no clean assertion could rely on. Added to the gamestate extractor, type definition, OpenRPC schema, and API docs. --- docs/api.md | 1 + src/lua/utils/gamestate.lua | 5 +++++ src/lua/utils/openrpc.json | 4 ++++ src/lua/utils/types.lua | 1 + 4 files changed, 11 insertions(+) diff --git a/docs/api.md b/docs/api.md index c81363d2..29c7c2b2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -696,6 +696,7 @@ The complete game state returned by most methods. ```json { "state": "SELECTING_HAND", + "paused": false, "round_num": 1, "ante_num": 1, "money": 4, diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 3cbbf210..4783266d 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -794,6 +794,11 @@ function gamestate.get_gamestate() state = get_state_name(G.STATE), } + -- Pause flag: true while a blocking overlay is up (win screen, pause + -- menu, game over). Exposed so callers can detect a session stuck in a + -- paused state — e.g. endless mode after a win if the overlay is left up. + state_data.paused = G.SETTINGS and G.SETTINGS.paused or false + -- Basic game info if G.GAME then state_data.round_num = G.GAME.round or 0 diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index ce242f14..df3a15c1 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -889,6 +889,10 @@ "state": { "$ref": "#/components/schemas/State" }, + "paused": { + "type": "boolean", + "description": "Whether the game is paused by a blocking overlay (win screen, pause menu, game over)" + }, "round_num": { "type": "integer", "description": "Current round number" diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 605653cf..5c522071 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -13,6 +13,7 @@ ---@field stake Stake? Current selected stake ---@field seed string? Seed used for the run ---@field state State Current game state +---@field paused boolean Whether the game is paused by a blocking overlay (win screen, pause menu, game over) ---@field round_num integer Current round number ---@field ante_num integer Current ante number ---@field money integer Current money amount From a64f996709b27f46bdec0237ae82d1367310d907 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Mon, 15 Jun 2026 08:46:15 +0200 Subject: [PATCH 118/121] test(lua): assert paused field instead of timing in endless play The endless-mode regression test asserted elapsed < 5s, a flaky wall-clock check that measures speed rather than game state. Rewrite it to assert paused=false on the endless play via the new GameState field. Verified red against the pre-fix play.lua (paused=true failure) and green with the fix restored. --- tests/lua/endpoints/test_play.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 27c41999..1b4680a9 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -1,7 +1,5 @@ """Tests for src/lua/endpoints/play.lua""" -import time - import httpx from tests.lua.conftest import ( @@ -89,15 +87,13 @@ def test_play_valid_cards_and_game_over(self, client: httpx.Client) -> None: assert_gamestate_response(response, state="GAME_OVER") def test_play_endless_mode_after_won(self, client: httpx.Client) -> None: - """Endless-mode play stays responsive after winning ante 8. - - Winning the ante-8 boss raises the win overlay and pauses the game. - ``play`` must dismiss that overlay so the game keeps running on turbo - time; otherwise the bot is left in a paused session where every - subsequent endless play crawls on wall-clock time. + """Endless-mode play runs unpaused after winning ante 8. - Driven live rather than via a save/load fixture: ``load`` resets the - run and discards the paused/overlay state, which would mask the bug. + Winning the ante-8 boss raises the win overlay and pauses the game + (``G.SETTINGS.paused = true``). ``play`` must dismiss that overlay so + the endless run keeps running on turbo time; otherwise the game is + left paused indefinitely and every subsequent play reports + ``paused=true``. """ # Drive to the ante-8 boss and win it. api(client, "menu") @@ -121,17 +117,12 @@ def test_play_endless_mode_after_won(self, client: httpx.Client) -> None: assert_gamestate_response(entering, state="SELECTING_HAND") assert entering["result"]["won"] is True - # An endless play must stay responsive. If the win overlay was left - # up, the game stays paused and this runs on wall-clock time (~9s - # instead of ~2s) — a permanently degraded bot session. - start = time.monotonic() + # An endless play must run unpaused. If the win overlay was left up, + # the game stays paused forever and the response reports paused=true. response = api(client, "play", {"cards": [0, 1, 2, 3, 4]}) - elapsed = time.monotonic() - start assert_gamestate_response(response) - assert response["result"]["won"] is True - assert elapsed < 5, ( - f"endless play took {elapsed:.1f}s — game left paused after win " - "(win overlay not dismissed)" + assert response["result"]["paused"] is False, ( + "endless play left the game paused — win overlay not dismissed" ) From ec95a7b17a0806d69810f96f9a2111de33eb90ea Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 17 Jun 2026 22:37:07 +0200 Subject: [PATCH 119/121] docs(ci): version docs with mike for main, tags, and dev Adopt mike so each release keeps a frozen snapshot while main and dev stay live. The version selector (extra.version.provider: mike) reads versions.json that mike generates on the gh-pages branch. Routing on push: - main -> /latest (plus root redirect via set-default) - v* -> /<version> (frozen per-tag snapshot) - dev -> /dev The old `mkdocs gh-deploy --force` wiped gh-pages on every push, which is incompatible with keeping multiple versions side by side. Mike takes over, committing one directory per version and leaving prior versions untouched. fetch-depth: 0 lets mike read and extend gh-pages history, and the mkdocs-material build cache is restored. Verified locally by dry-running all three routes against a throwaway branch: each produced its own version directory, versions.json listed all entries, and the root index.html redirects to /latest. --- .github/workflows/deploy_docs.yml | 24 +++++++++++++------- mkdocs.yml | 4 ++++ pyproject.toml | 1 + uv.lock | 37 +++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index a5c0110c..1823f95f 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -1,11 +1,10 @@ name: Deploy Documentation on: push: - branches: [main] + branches: [main, dev] + tags: ['v*'] permissions: contents: write - pages: write - id-token: write concurrency: group: "pages" cancel-in-progress: false @@ -14,15 +13,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version-file: ".python-version" - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -32,6 +31,7 @@ jobs: run: | uv venv uv sync --dev + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - name: Initialize cache uses: actions/cache@v4 with: @@ -39,7 +39,15 @@ jobs: path: .cache restore-keys: | mkdocs-material- - - name: Deploy documentation + - name: Deploy run: | source .venv/bin/activate - mkdocs gh-deploy --force + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + VER="${GITHUB_REF#refs/tags/v}" + mike deploy --push "$VER" + elif [[ "$GITHUB_REF" == refs/heads/main ]]; then + mike deploy --push --update-aliases latest + mike set-default --push latest + else + mike deploy --push dev + fi diff --git a/mkdocs.yml b/mkdocs.yml index 2f080698..260555ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,10 @@ theme: name: Switch to light mode extra: generator: false + version: + provider: mike + default: latest + alias: true plugins: - search - llmstxt: diff --git a/pyproject.toml b/pyproject.toml index 10728c5f..a51b13db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dev = [ "mdformat-gfm-alerts>=2.0.0", "mdformat-mkdocs>=5.1.1", "mdformat-simple-breaks>=0.0.1", + "mike>=2.2.0", "mkdocs>=1.6.1", "mkdocs-llmstxt>=0.5.0", "mkdocs-material>=9.7.1", diff --git a/uv.lock b/uv.lock index 5ad6709c..6b360dda 100644 --- a/uv.lock +++ b/uv.lock @@ -67,6 +67,7 @@ dev = [ { name = "mdformat-gfm-alerts" }, { name = "mdformat-mkdocs" }, { name = "mdformat-simple-breaks" }, + { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-llmstxt" }, { name = "mkdocs-material" }, @@ -98,6 +99,7 @@ dev = [ { name = "mdformat-gfm-alerts", specifier = ">=2.0.0" }, { name = "mdformat-mkdocs", specifier = ">=5.1.1" }, { name = "mdformat-simple-breaks", specifier = ">=0.0.1" }, + { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-llmstxt", specifier = ">=0.5.0" }, { name = "mkdocs-material", specifier = ">=9.7.1" }, @@ -507,6 +509,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1" @@ -694,6 +713,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -983,6 +1011,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "verspec" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From 822ad82228b006410f6865952cd418b7b30e28e1 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 24 Jun 2026 09:17:23 +0200 Subject: [PATCH 120/121] docs(terminology): disambiguate profile terminology in CONTEXT.md Split overloaded "profile" term into "save profile" (Balatro's numbered in-game save slots loaded from .jkr) and "runtime profile" (named settings directories under src/lua/profiles/). Updated "BalatroBot profile" to reference the new terminology and added "Avoid" notes for deprecated terms. This clarifies the distinction between Balatro's native save system and our mod's settings override mechanism. --- CONTEXT.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 475e28de..90251987 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,7 +27,8 @@ Glossary of terms used in the BalatroBot project. | **pack** / **booster** / **booster pack** | Same thing. A purchasable pack of cards you open and choose from. | | **test fixture** | A JSON file of API call sequences that reproduces a specific game state. Not a pytest fixture. Generated by `make fixtures` and loaded by tests. Fixture keys follow `<state>--<key1>-<value1>[--<key2>-<value2>...]` where keys use dotted paths for nested fields (e.g. `shop.cards[0].set`) and spaces in values are replaced with `+`. | | **`dev` marker** | `@pytest.mark.dev` — tags tests currently being developed. Run with `pytest -m dev` to isolate. Remove when done. Ephemeral, not permanent. | -| **profile** | Overloaded by design — meaning is clear from context. Can refer to: (1) a **Balatro in-game profile** (save slot 1–3 with a name like "BalatroBot"), or (2) a **settings profile** (a named directory under `src/lua/profiles/` that overrides `G.SETTINGS` and `G.PROFILES` via `--settings`). | -| **BalatroBot profile** | A Balatro in-game profile named exactly `BalatroBot`. Required for the mod to activate — if no profile with this name exists, the HTTP server does not start and no settings overrides are applied. | +| **save profile** | Balatro's numbered in-game save slot (1–3), identified by a `name` field (e.g. `BalatroBot`). Loaded from `.jkr` at boot into `G.PROFILES[n]`. What the activation gate reads. _Avoid_: in-game profile, save slot. | +| **runtime profile** | A named directory under `src/lua/profiles/` (e.g. `fast`, `turbo`) whose `settings.lua`/`profile.lua` are deep-merged into `G.SETTINGS`/`G.PROFILES` after the gate passes. Selected via `--settings` / `BALATROBOT_SETTINGS`. _Avoid_: settings profile (that term belongs to `S1M0N38/balatrosettings`), preset. | +| **BalatroBot profile** | A save profile named exactly `BalatroBot`. Required for the mod to activate — if no save profile with this name exists, the HTTP server does not start and no runtime profile is applied. | | **render mode** | How BalatroBot handles rendering: `headfull` (normal rendering, default), `headless` (no rendering, no window), or `ondemand` (render only when triggered by an API call). Set via `--render` or `BALATROBOT_RENDER`. | | **endpoint** | A single API operation (e.g. `play`, `start`, `health`). Each endpoint is a Lua module in `src/lua/endpoints/`. Called "method" in JSON-RPC contexts and exposed as `balatrobot api <endpoint>` in the CLI. | From c18d2c0c418073659bc29e1f5d7dcf0854ce23c1 Mon Sep 17 00:00:00 2001 From: S1M0N38 <bertolottosimone@gmail.com> Date: Wed, 24 Jun 2026 09:20:36 +0200 Subject: [PATCH 121/121] docs(skill): simplify commit command example in git-commit skill Remove unnecessary command substitution from git commit heredoc example. Using -F - to read from stdin is simpler and more direct than wrapping the heredoc in $(cat ...). --- .agents/skills/git-commit/SKILL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.agents/skills/git-commit/SKILL.md b/.agents/skills/git-commit/SKILL.md index 9fefb7e9..1f042172 100644 --- a/.agents/skills/git-commit/SKILL.md +++ b/.agents/skills/git-commit/SKILL.md @@ -26,10 +26,9 @@ allowed-tools: Bash 5. **Commit:** Execute using heredoc: ```bash - git commit -m "$(cat <<'EOF' + git commit -F - <<'EOF' <message here> EOF - )" ``` 6. **Iterate:** Repeat steps 3-5 until all logical groups are committed.