From 374ea2169f0fa7167d5be64cb85192409a03213c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 24 Feb 2026 11:10:29 +0100 Subject: [PATCH 01/23] 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 87f12bbe204272996df83406fe1156962e6d9884 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 24 Feb 2026 12:08:49 +0100 Subject: [PATCH 02/23] 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 660621c63c5068aff9caf4fb75e6fbe677373eeb Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:39:15 +0100 Subject: [PATCH 03/23] 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 de824d7ffc65357d7cd3ca06ca9ac66ea584364e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:40:11 +0100 Subject: [PATCH 04/23] 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 6437fae108e6c00b01c9c053aed3e27dd72b797d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:41:01 +0100 Subject: [PATCH 05/23] 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 32b789bf..727a6969 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 e0791adcf6d01d7bccda68e7f0a71039a66f35fe Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 11:41:53 +0100 Subject: [PATCH 06/23] 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 84f7c59ceb5183115b47320d2eb3223069c2980c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 12:19:30 +0100 Subject: [PATCH 07/23] 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 e009e82d474e6e28a62df92675b28f7b48cd24b4 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 12:28:15 +0100 Subject: [PATCH 08/23] 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 d40d645bfe2afad80d9c86f50d9cc61aed55901e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 25 Feb 2026 13:07:56 +0100 Subject: [PATCH 09/23] 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 727a6969..aea1d7cc 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 73b6148bdd3da92badc3b210dc3651b8c97a77da Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 28 Feb 2026 14:23:23 +0100 Subject: [PATCH 10/23] 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 48c4cbc77dd6454eb326aee7d9740b33353a6b88 Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sat, 4 Apr 2026 19:44:10 -0400 Subject: [PATCH 11/23] Expose most_played_poker_hand and ancient_suit in round info Adds two fields to extract_round_info(): - most_played_poker_hand: The Ox boss's locked hand type from G.GAME.current_round - ancient_suit: Ancient Joker's current rotating suit, mapped to single-letter codes Co-Authored-By: Claude Opus 4.6 --- src/lua/utils/gamestate.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index a7cc2b97..5867c7b1 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -468,6 +468,18 @@ local function extract_round_info() round.chips = G.GAME.chips end + -- The Ox: which hand type triggers money loss + if G.GAME.current_round.most_played_poker_hand then + round.most_played_poker_hand = G.GAME.current_round.most_played_poker_hand + end + + -- Ancient Joker's current rotating suit + if G.GAME.current_round.ancient_card and G.GAME.current_round.ancient_card.suit then + local suit = G.GAME.current_round.ancient_card.suit + local suit_map = { Hearts = "H", Diamonds = "D", Clubs = "C", Spades = "S" } + round.ancient_suit = suit_map[suit] or suit + end + return round end From 800078a8e3c2d6620ffd72c463ea0ad103f68183 Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sat, 4 Apr 2026 19:46:19 -0400 Subject: [PATCH 12/23] Add blind param to set endpoint for forcing boss blinds Adds a "blind" string parameter (e.g. "bl_ox", "bl_flint") that sets the upcoming boss blind and rebuilds the UI to reflect the change. Used by integration tests to force specific boss encounters. Co-Authored-By: Claude Opus 4.6 --- src/lua/endpoints/set.lua | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index 5885d455..d92eb7de 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 blind string? Boss blind key (e.g. "bl_flint") -- sets the upcoming boss blind -- ========================================================================== -- Set Endpoint @@ -60,6 +61,11 @@ return { required = false, description = "Re-stock shop with new items", }, + blind = { + type = "string", + required = false, + description = "Boss blind key (e.g. 'bl_flint') -- sets the upcoming boss blind", + }, }, requires_state = nil, @@ -87,6 +93,7 @@ return { and args.hands == nil and args.discards == nil and args.shop == nil + and args.blind == nil then send_response({ message = "Must provide at least one field to set", @@ -167,6 +174,47 @@ return { G.GAME.current_round.discards_left = args.discards end + -- Set boss blind + if args.blind then + if not G.P_BLINDS[args.blind] then + send_response({ + message = "Unknown blind key: " .. tostring(args.blind), + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + G.GAME.round_resets.blind_choices.Boss = args.blind + G.RESET_BLIND_STATES = true + + -- Rebuild the boss blind UIBox so select_blind picks up the new blind. + -- Mirrors the logic in G.FUNCS.reroll_boss (button_callbacks.lua). + if G.blind_select_opts and G.blind_select_opts.boss then + local par = G.blind_select_opts.boss.parent + G.blind_select_opts.boss:remove() + G.blind_select_opts.boss = UIBox{ + T = {par.T.x, 0, 0, 0}, + definition = + {n=G.UIT.ROOT, config={align = "cm", colour = G.C.CLEAR}, nodes={ + UIBox_dyn_container( + {create_UIBox_blind_choice('Boss')}, + false, + get_blind_main_colour('Boss'), + mix_colours(G.C.BLACK, get_blind_main_colour('Boss'), 0.8) + ) + }}, + config = {align="bmi", + offset = {x=0, y=G.ROOM.T.y + 9}, + major = par, + xy_bond = 'Weak' + } + } + par.config.object = G.blind_select_opts.boss + par.config.object:recalculate() + G.blind_select_opts.boss.parent = par + G.blind_select_opts.boss.alignment.offset.y = 0 + end + end + if args.shop then if G.STATE ~= G.STATES.SHOP then send_response({ From af78bb3790024a1ccc7fcd47c9aed82ff95fa14e Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sat, 4 Apr 2026 19:59:22 -0400 Subject: [PATCH 13/23] Port local mod fixes: edition detection, pack robustness, sell/rearrange/start fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gamestate.lua: - Expanded edition detection with multiple fallback paths (type, booleans, SMODS key) - Expose edition_mult, edition_chips, edition_x_mult (via get_edition to avoid Glass contamination) - Fallback get_chip_mult/get_chip_bonus for editions not in card.edition table - Expose enhancement_x_mult, perma_bonus, rarity, and joker ability scoring values buy.lua / pack.lua: - Fix pack type detection: check hand cards dealt instead of inferring from first card's set (Black Hole is Spectral but appears in Celestial packs) - Fix hand count: use min(deck_size, hand_limit) for small decks - Add re-entrancy guard to prevent double-firing use_card during animations sell.lua: - Remove card count check from completion — Invisible Joker spawns replacement on sell rearrange.lua: - Set card.rank and trigger sort/set_ranks/align_cards for visual re-layout start.lua: - Wrap setup_run/exit_overlay_menu in pcall, retry with delete_run on failure - Reset win overlay flags on new run play.lua / discard.lua: - Remove blockable/created_on_pause event flags - Wait for win_overlay_dismissed before proceeding on win Co-Authored-By: Claude Opus 4.6 --- src/lua/endpoints/buy.lua | 23 +++--- src/lua/endpoints/discard.lua | 2 - src/lua/endpoints/pack.lua | 27 +++++-- src/lua/endpoints/play.lua | 14 ++-- src/lua/endpoints/rearrange.lua | 17 ++++- src/lua/endpoints/sell.lua | 18 +++-- src/lua/endpoints/start.lua | 25 ++++++- src/lua/utils/gamestate.lua | 121 +++++++++++++++++++++++++++++++- 8 files changed, 203 insertions(+), 44 deletions(-) diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 77e42963..615f46c0 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -238,25 +238,26 @@ return { end elseif args.pack then local money_deducted = (G.GAME.dollars == initial_money - card.cost.buy) - local pack_ready = ( - G.pack_cards - and not G.pack_cards.REMOVED - and G.pack_cards.cards[1] - and G.STATE_COMPLETE - and G.STATE == G.STATES.SMODS_BOOSTER_OPENED - ) + local has_pack = G.pack_cards and not G.pack_cards.REMOVED and G.pack_cards.cards and G.pack_cards.cards[1] + local state_ok = G.STATE_COMPLETE and G.STATE == G.STATES.SMODS_BOOSTER_OPENED + local pack_ready = has_pack and state_ok + if money_deducted and pack_ready then - -- Check if this pack type needs hand (Arcana/Spectral packs) - local pack_key = G.pack_cards.cards[1].ability and G.pack_cards.cards[1].ability.set - local needs_hand = pack_key == "Tarot" or pack_key == "Spectral" + -- Check if this pack type needs hand (Arcana/Spectral packs deal hand cards) + -- Don't infer pack type from the first card's set — Black Hole is + -- set=Spectral but appears in Celestial packs, causing a false match. + local needs_hand = G.hand and G.hand.cards and #G.hand.cards > 0 if needs_hand then -- Wait for hand to be fully loaded and positioned local hand_limit = G.hand and G.hand.config and G.hand.config.card_limit or 8 + local deck_size = G.deck and G.deck.config and G.deck.config.card_count or 52 + local expected = math.min(deck_size, hand_limit) + local hand_count = G.hand and G.hand.cards and #G.hand.cards or 0 local hand_ready = G.hand and not G.hand.REMOVED and G.hand.cards - and #G.hand.cards == hand_limit + and hand_count >= expected and G.hand.T and G.hand.T.x local cards_positioned = hand_ready and G.hand.cards[1] and G.hand.cards[1].T and G.hand.cards[1].T.x diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 77316976..22d2f6e7 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -97,8 +97,6 @@ return { G.E_MANAGER:add_event(Event({ trigger = "immediate", blocking = false, - blockable = false, - created_on_pause = true, func = function() -- State progression for discard: -- Discard always continues current round: HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index d60efa80..2a9efafa 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -3,6 +3,10 @@ ---@type BB_LOGGER local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +-- Re-entrancy guard: prevents double-firing use_card when a previous +-- pack selection is still being processed (e.g. Black Hole animations). +local selection_in_progress = false + -- ========================================================================== -- Pack Select Endpoint Params -- ========================================================================== @@ -110,6 +114,15 @@ return { return end + -- Block re-entrant calls while a previous selection is processing + if selection_in_progress then + send_response({ + message = "Pack selection already in progress", + name = BB_ERROR_NAMES.NOT_ALLOWED, + }) + return + end + -- Validate pack_cards exists if not G.pack_cards or G.pack_cards.REMOVED then send_response({ @@ -239,6 +252,7 @@ return { local pack_choices_before = G.GAME.pack_choices or 0 + selection_in_progress = true G.FUNCS.use_card(btn) -- Wait for action to complete - check pack_choices to determine expected state @@ -255,6 +269,7 @@ return { and G.STATE == G.STATES.SMODS_BOOSTER_OPENED if pack_stable then + selection_in_progress = false sendDebugMessage("Return pack() after selection (more choices remain)", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true @@ -265,6 +280,7 @@ return { local back_to_shop = G.STATE == G.STATES.SHOP if pack_closed and back_to_shop then + selection_in_progress = false sendDebugMessage("Return pack() after selection", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true @@ -279,6 +295,7 @@ return { -- Handle skip if args.skip then + selection_in_progress = false -- Clear guard so skip can proceed after stuck selection local pack_count = G.pack_cards.config and G.pack_cards.config.card_count or 0 sendDebugMessage(string.format("Pack: skipping (%d cards remaining)", pack_count), "BB.ENDPOINTS") G.FUNCS.skip_booster({}) @@ -304,12 +321,10 @@ return { end -- Wait for hand cards to load for Arcana and Spectral packs - local pack_key = G.pack_cards - and G.pack_cards.cards - and G.pack_cards.cards[1] - and G.pack_cards.cards[1].ability - and G.pack_cards.cards[1].ability.set - local needs_hand = pack_key == "Tarot" or pack_key == "Spectral" + -- Check if hand cards are dealt (Arcana/Spectral packs deal hand cards). + -- Don't infer pack type from the first card's set — Black Hole is + -- set=Spectral but appears in Celestial packs, causing a false match. + local needs_hand = G.hand and G.hand.cards and #G.hand.cards > 0 if needs_hand then -- Wait for hand cards to be fully loaded and positioned diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 1b9f0a98..2c63b56f 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -3,6 +3,8 @@ ---@type BB_LOGGER local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +-- Win overlay state is now tracked in BB_GAMESTATE (shared with love.update) + -- ========================================================================== -- Play Endpoint Params -- ========================================================================== @@ -92,8 +94,6 @@ return { G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, - blockable = false, - created_on_pause = true, func = function() -- State progression: -- Loss: HAND_PLAYED -> NEW_ROUND -> (game paused) -> GAME_OVER @@ -121,12 +121,10 @@ return { return false end - -- Game is won - if G.GAME.won then - sendDebugMessage("Return play() - won", "BB.ENDPOINTS") - local state_data = BB_GAMESTATE.get_gamestate() - send_response(state_data) - return true + -- Game is won — love.update auto-dismisses the overlay. + -- Wait here until that's done before looking for cash_out button. + if G.GAME.won and not BB_GAMESTATE.win_overlay_dismissed then + return false end -- Wait for first scoring row (blind1) to be added to the UI diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index e8ec143f..bcf21ac6 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -185,8 +185,10 @@ return { G.consumeables.cards = new_array end - -- Update order fields on each card + -- Update order fields, rank, and visual positions on each card for i, card in ipairs(new_array) do + -- Set rank so align_cards() respects our ordering + card.rank = i if rearrange_type == "hand" then card.config.card.order = i if card.config.center then @@ -202,6 +204,19 @@ return { end end + -- Trigger visual re-layout (mirrors gamepad d-pad reordering in controller.lua) + local area + if rearrange_type == "hand" then + area = G.hand + elseif rearrange_type == "jokers" then + area = G.jokers + else + area = G.consumeables + end + table.sort(area.cards, function(a, b) return a.rank < b.rank end) + area:set_ranks() + area:align_cards() + -- Wait for completion: state should remain stable after rearranging G.E_MANAGER:add_event(Event({ trigger = "condition", diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 5d112222..31ef538b 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -121,17 +121,13 @@ return { trigger = "condition", blocking = false, func = function() - -- Check all 5 completion criteria local current_area = sell_type == "joker" and G.jokers or G.consumeables local current_array = current_area.cards - -- 1. Card count decreased by 1 - local count_decreased = (current_area.config.card_count == initial_count - 1) - - -- 2. Money increased by sell_cost + -- 1. Money increased by sell_cost local money_increased = (G.GAME.dollars == expected_money) - -- 3. Card no longer exists (verify by unique_val) + -- 2. Card no longer exists (by sort_id) local card_gone = true for _, c in ipairs(current_array) do if c.sort_id == card_id then @@ -140,14 +136,16 @@ return { end end - -- 4. State stability + -- 3. State stability local state_stable = G.STATE_COMPLETE == true - -- 5. Still in valid state + -- 4. Still in valid state local valid_state = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND) - -- All conditions must be met - if count_decreased and money_increased and card_gone and state_stable and valid_state then + -- Note: card count is NOT checked here — some jokers (e.g. Invisible Joker) + -- spawn a replacement on sell, leaving count unchanged. card_gone + money_increased + -- uniquely identify completion without relying on count. + if money_increased and card_gone and state_stable and valid_state then sendDebugMessage("Return sell()", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 5bcefa62..aef07852 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -102,9 +102,16 @@ return { return end + -- Reset win overlay flags from previous run + BB_GAMESTATE.win_overlay_dismissed = false + BB_GAMESTATE.win_overlay_dismissing = false + -- Reset the game (setup_run and exit_overlay_menu) - G.FUNCS.setup_run({ config = {} }) - G.FUNCS.exit_overlay_menu() + -- Use pcall to handle cases where the overlay isn't fully ready + pcall(function() + G.FUNCS.setup_run({ config = {} }) + G.FUNCS.exit_overlay_menu() + end) -- Find and set the deck using the mapped deck name local deck_found = false @@ -144,7 +151,19 @@ return { .. tostring(args.seed or "none"), "BB.ENDPOINTS" ) - G.FUNCS.start_run(nil, run_params) + local ok, err = pcall(G.FUNCS.start_run, nil, run_params) + if not ok then + -- Clean up stale state and retry + pcall(function() G:delete_run() end) + local ok2, err2 = pcall(G.FUNCS.start_run, nil, run_params) + if not ok2 then + send_response({ + message = "Failed to start run: " .. tostring(err2), + name = BB_ERROR_NAMES.INTERNAL_ERROR, + }) + return + end + end -- Wait for run to start using Balatro's Event Manager G.E_MANAGER:add_event(Event({ diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 5867c7b1..a3d7ecef 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -232,14 +232,85 @@ local function extract_card_modifier(card) modifier.seal = string.upper(card.seal) end - -- Edition (table with type/key) - if card.edition and card.edition.type then - modifier.edition = string.upper(card.edition.type) + -- Edition: use the card's own scoring methods for reliable detection. + -- card:get_chip_mult() returns the holo mult, get_chip_bonus() includes foil chips, + -- get_chip_x_mult() returns polychrome xmult. These work regardless of how + -- the edition table is structured internally. + if card.edition then + -- Type string for identification + if card.edition.type then + modifier.edition = string.upper(card.edition.type) + elseif card.edition.holo then + modifier.edition = "HOLO" + elseif card.edition.foil then + modifier.edition = "FOIL" + elseif card.edition.polychrome then + modifier.edition = "POLYCHROME" + elseif card.edition.negative then + modifier.edition = "NEGATIVE" + elseif card.edition.key then + -- SMODS: edition.key = "e_holo" / "e_foil" / "e_polychrome" / "e_negative" + local etype = card.edition.key:gsub("^e_", "") + modifier.edition = string.upper(etype) + end + + -- Numeric scoring values + if card.edition.mult and card.edition.mult ~= 0 then + modifier.edition_mult = card.edition.mult + end + if card.edition.chips and card.edition.chips ~= 0 then + modifier.edition_chips = card.edition.chips + end + -- Use get_edition() for x_mult: the raw card.edition.x_mult can be + -- contaminated by enhancement values (Glass x2 overwrites Polychrome x1.5). + -- get_edition() returns the correct edition-only value. + if card.get_edition then + local ok, ed = pcall(function() return card:get_edition() end) + if ok and ed and ed.x_mult_mod and ed.x_mult_mod ~= 0 then + modifier.edition_x_mult = ed.x_mult_mod + end + elseif card.edition.x_mult and card.edition.x_mult ~= 0 then + modifier.edition_x_mult = card.edition.x_mult + end + end + -- Fallback: use card scoring methods directly (catches editions not in card.edition table) + -- Skip if the card has a MULT or LUCKY enhancement — get_chip_mult() includes + -- enhancement mult, which would create a phantom HOLO edition. + local has_mult_enhancement = card.ability and card.ability.effect + and (card.ability.effect == "Mult Card" or card.ability.effect == "Lucky Card") + if not modifier.edition and card.get_chip_mult and not has_mult_enhancement then + local ok, emult = pcall(function() return card:get_chip_mult() end) + if ok and emult and emult > 0 then + modifier.edition = "HOLO" + modifier.edition_mult = emult + end + end + -- Note: removed get_chip_x_mult() fallback here — it returns the enhancement + -- x_mult (Glass 2.0), not the edition x_mult (Polychrome 1.5). Edition detection + -- via get_edition() above is now the primary path. + if not modifier.edition_chips and card.get_chip_bonus then + -- get_chip_bonus includes base nominal + ability.bonus + perma_bonus + edition chips + -- We already handle perma_bonus separately, so only check for foil-level chips + local ok, echips = pcall(function() return card:get_chip_bonus() end) + if ok and echips then + local base_nominal = (card.base and card.base.nominal) or 0 + local ability_bonus = (card.ability and card.ability.bonus) or 0 + local perma = (card.ability and card.ability.perma_bonus) or 0 + local edition_chips = echips - base_nominal - ability_bonus - perma + if edition_chips > 0 then + modifier.edition = modifier.edition or "FOIL" + modifier.edition_chips = edition_chips + end + end end -- Enhancement (from ability.name for enhanced cards) if card.ability and card.ability.effect and card.ability.effect ~= "Base" then modifier.enhancement = string.upper(card.ability.effect:gsub(" Card", "")) + -- Expose enhancement x_mult separately (Glass = 2.0) + if card.ability.x_mult and card.ability.x_mult ~= 1 then + modifier.enhancement_x_mult = card.ability.x_mult + end end -- Eternal (boolean from ability) @@ -279,6 +350,50 @@ local function extract_card_value(card) -- Effect description (for all cards) value.effect = get_card_ui_description(card) + -- Permanent chip bonus (from Hiker etc.) — only for playing cards + if card.ability then + if card.ability.perma_bonus and card.ability.perma_bonus ~= 0 then + value.perma_bonus = card.ability.perma_bonus + end + end + + -- Joker rarity (1=Common, 2=Uncommon, 3=Rare, 4=Legendary) + if card.config and card.config.center and card.config.center.rarity then + value.rarity = card.config.center.rarity + end + + -- Joker ability data: expose actual scoring values instead of requiring + -- text parsing. Includes accumulated values for scaling jokers. + if card.ability then + local ab = {} + -- extra: varies by joker — can be number or table with chips/mult/Xmult/etc. + if card.ability.extra ~= nil then + if type(card.ability.extra) == "table" then + -- Shallow copy the table + for k, v in pairs(card.ability.extra) do + if type(v) ~= "table" and type(v) ~= "function" then + ab[k] = v + end + end + else + ab.extra = card.ability.extra + end + end + -- Config-level scoring fields (hand-type jokers like Jolly, Sly, etc.) + if card.ability.t_mult and card.ability.t_mult ~= 0 then ab.t_mult = card.ability.t_mult end + if card.ability.t_chips and card.ability.t_chips ~= 0 then ab.t_chips = card.ability.t_chips end + if card.ability.mult and card.ability.mult ~= 0 then ab.mult = card.ability.mult end + if card.ability.x_mult and card.ability.x_mult ~= 0 then ab.x_mult = card.ability.x_mult end + -- Driver's License enhanced card count + if card.ability.driver_tally then ab.driver_tally = card.ability.driver_tally end + -- Loyalty Card: remaining hands until trigger + if card.ability.loyalty_remaining ~= nil then ab.loyalty_remaining = card.ability.loyalty_remaining end + -- Only include if non-empty + if next(ab) ~= nil then + value.ability = ab + end + end + return value end From 62b764d1bb83303b89fa66f7fa7d456a7529a185 Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sat, 4 Apr 2026 20:14:32 -0400 Subject: [PATCH 14/23] Add win overlay auto-dismiss for endless mode support When the bot wins a run, Balatro shows a "YOU WIN" overlay that pauses the game. Since event handlers can't fire while paused, this adds a two-phase dismissal in love.update that auto-dismisses the overlay so the bot can continue into endless mode. Play endpoint now waits for the overlay to be dismissed before returning. Co-Authored-By: Claude Opus 4.6 --- balatrobot.lua | 3 +++ src/lua/utils/gamestate.lua | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/balatrobot.lua b/balatrobot.lua index 90b3a54f..df706bbb 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -73,6 +73,9 @@ local love_update = love.update love.update = function(dt) ---@diagnostic disable-line: duplicate-set-field -- Check for GAME_OVER before game logic runs BB_GAMESTATE.check_game_over() + -- Dismiss win overlay when paused — event handlers can't run while paused, + -- so we do it here in love.update which always runs + BB_GAMESTATE.check_win_overlay() love_update(dt) BB_SERVER.update(BB_DISPATCHER) end diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index e1a0e037..837aeb36 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -1061,6 +1061,14 @@ end -- normal event-based detection from working. gamestate.on_game_over = nil +-- Tracks whether we've already dismissed the win overlay for endless mode. +-- The dismissal happens in love.update (check_win_overlay) because the +-- overlay sets G.SETTINGS.paused=true which blocks event processing. +-- Two-phase: dismiss first, then confirm removal on a later frame so the +-- game has time to fully process the overlay removal before the bot resumes. +gamestate.win_overlay_dismissed = false +gamestate.win_overlay_dismissing = false + ---Check and trigger GAME_OVER callback if state is GAME_OVER ---Called from love.update before game logic runs function gamestate.check_game_over() @@ -1070,4 +1078,27 @@ function gamestate.check_game_over() end end +---Auto-dismiss the "YOU WIN" overlay to continue into endless mode. +---Called from love.update so it works even when the game is paused. +---Two-phase: first frame dismisses the overlay, subsequent frame confirms +---it's gone before setting win_overlay_dismissed (which unblocks play.lua). +function gamestate.check_win_overlay() + if gamestate.win_overlay_dismissed then return end + if not G.GAME or not G.GAME.won then return end + + -- Phase 2: overlay was dismissed on a previous frame, confirm it's gone + if gamestate.win_overlay_dismissing then + if not G.OVERLAY_MENU then + gamestate.win_overlay_dismissed = true + end + return + end + + -- Phase 1: overlay is visible, dismiss it now + if not G.OVERLAY_MENU then return end + G.FUNCS.exit_overlay_menu() + gamestate.on_game_over = nil + gamestate.win_overlay_dismissing = true +end + return gamestate From 457d4038f9bd26896a34328b3ce63c37f25df88b Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sat, 11 Apr 2026 09:30:46 -0400 Subject: [PATCH 15/23] Sync local mod changes: cash_out race fix, set debuff, sell/error cleanup - Fix cash_out race condition: wait for scoring_complete() before calling G.FUNCS.cash_out to prevent nil round_eval crash - Add set(debuff=true) to re-apply blind debuffs after add() - Add highlight endpoint for toggling card highlights - Fix voucher add to use SMODS.add_card instead of add_voucher_to_shop - Remove sell during SMODS_BOOSTER_OPENED (simplify sell states) - Clean up error messages (remove unnecessary usage hints) - Remove Tag type/enum (replaced with inline tag_name/tag_effect) - Simplify gamestate extraction, update openrpc spec and tests Co-Authored-By: Claude Opus 4.6 --- balatrobot.json | 4 +- balatrobot.lua | 1 + docs/api.md | 55 +------- docs/index.md | 2 +- docs/installation.md | 2 +- src/lua/endpoints/add.lua | 15 ++- src/lua/endpoints/buy.lua | 18 ++- src/lua/endpoints/cash_out.lua | 48 +++++-- src/lua/endpoints/discard.lua | 4 +- src/lua/endpoints/highlight.lua | 50 +++++++ src/lua/endpoints/pack.lua | 16 +-- src/lua/endpoints/play.lua | 2 +- src/lua/endpoints/sell.lua | 24 +--- src/lua/endpoints/set.lua | 28 +++- src/lua/endpoints/skip.lua | 2 +- src/lua/endpoints/start.lua | 2 + src/lua/endpoints/use.lua | 16 +-- src/lua/utils/enums.lua | 26 ---- src/lua/utils/gamestate.lua | 156 ++++----------------- src/lua/utils/openrpc.json | 51 ++----- src/lua/utils/types.lua | 9 +- tests/lua/endpoints/test_add.py | 2 +- tests/lua/endpoints/test_buy.py | 10 +- tests/lua/endpoints/test_gamestate.py | 186 ++------------------------ tests/lua/endpoints/test_pack.py | 29 ---- tests/lua/endpoints/test_skip.py | 10 -- uv.lock | 2 +- 27 files changed, 216 insertions(+), 554 deletions(-) create mode 100644 src/lua/endpoints/highlight.lua diff --git a/balatrobot.json b/balatrobot.json index 829121fa..17513ae1 100644 --- a/balatrobot.json +++ b/balatrobot.json @@ -15,8 +15,8 @@ "badge_colour": "4CAF50", "badge_text_colour": "FFFFFF", "display_name": "BB", - "version": "1.4.1", + "version": "1.4.0", "dependencies": [ - "Steamodded (>=1.~)" + "Steamodded (>=0.0.1)" ] } diff --git a/balatrobot.lua b/balatrobot.lua index df706bbb..4e0aaa9c 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -25,6 +25,7 @@ BB_ENDPOINTS = { "src/lua/endpoints/skip.lua", "src/lua/endpoints/select.lua", -- Play/discard endpoints + "src/lua/endpoints/highlight.lua", "src/lua/endpoints/play.lua", "src/lua/endpoints/discard.lua", -- Cash out endpoint diff --git a/docs/api.md b/docs/api.md index c93b7fc1..6f3ed22f 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. Available in SHOP, SELECTING_HAND states, and when a Buffoon pack is open (to make room for new jokers). +Sell a joker or consumable. **Parameters:** (exactly one required) @@ -406,7 +406,7 @@ Sell a joker or consumable. Available in SHOP, SELECTING_HAND states, and when a **Returns:** [GameState](#gamestate-schema) -**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED` +**Errors:** `BAD_REQUEST`, `NOT_ALLOWED` **Example:** @@ -698,7 +698,6 @@ The complete game state returned by most methods. "seed": "ABC123", "won": false, "used_vouchers": {}, - "tags": [ ... ], "hands": { ... }, "round": { ... }, "blinds": { ... }, @@ -781,23 +780,8 @@ Represents a card area (hand, jokers, consumables, shop, etc.). "name": "Small Blind", "effect": "No special effect", "score": 300, - "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" + "tag_name": "Uncommon Tag", + "tag_effect": "Shop has a free Uncommon Joker" } ``` @@ -941,37 +925,6 @@ Represents a Balatro tag that provides bonuses when triggered. | `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. diff --git a/docs/index.md b/docs/index.md index 03af7a41..c6955690 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ BalatroBot
- BalatroBot
+ BalatroBot
API for developing Balatro bots
diff --git a/docs/installation.md b/docs/installation.md index 781b18a4..7b14edd6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,7 +13,7 @@ This guide covers installing the BalatroBot mod for Balatro. ### 1. Download BalatroBot -Download the latest release from the [releases page](https://github.com/coder/balatrobot/releases) or clone the repository. +Download the latest release from the [releases page](https://github.com/your-repo/balatrobot/releases) or clone the repository. ### 2. Copy to Mods Folder diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 05b4f790..0bee6a21 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, pack, or playing card)", + description = "Add a new card to the game (joker, consumable, voucher, or playing card)", schema = { key = { type = "string", required = true, - description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, p_* for packs, SUIT_RANK for playing cards like H_A)", + description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, 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_*), pack (p_*), or playing card (SUIT_RANK)", + message = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -378,6 +378,12 @@ 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 = { @@ -423,9 +429,6 @@ 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) diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index fc6d64c3..63e37dfd 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. Use `reroll` to restock the shop." + msg = "No jokers/consumables/cards in the shop. 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. Use `next_round` to advance to the next blind and restock the shop." + msg = "No packs to open" end send_response({ message = msg, @@ -136,8 +136,7 @@ return { message = "Cannot purchase joker card, joker slots are full. Current: " .. gamestate.jokers.count .. ", Limit: " - .. gamestate.jokers.limit - .. ". Sell a joker using `sell` to free a slot.", + .. gamestate.jokers.limit, name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -151,8 +150,7 @@ return { message = "Cannot purchase consumable card, consumable slots are full. Current: " .. gamestate.consumables.count .. ", Limit: " - .. gamestate.consumables.limit - .. ". Use `use` to activate a consumable or `sell` to remove one.", + .. gamestate.consumables.limit, name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -263,6 +261,12 @@ return { and G.hand.T and G.hand.T.x local cards_positioned = hand_ready and G.hand.cards[1] and G.hand.cards[1].T and G.hand.cards[1].T.x + if not done and money_deducted then + sendDebugMessage(string.format( + "buy(pack) hand wait: count=%d expected=%d limit=%d deck=%d positioned=%s", + hand_count, expected, hand_limit, deck_size, tostring(cards_positioned ~= nil) + ), "BB.ENDPOINTS") + end done = hand_ready and cards_positioned else done = true diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index 03e7ee64..adac2762 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -25,7 +25,6 @@ return { ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) sendDebugMessage("Init cash_out()", "BB.ENDPOINTS") - G.FUNCS.cash_out({ config = {} }) local num_items = function(area) local count = 0 @@ -39,21 +38,48 @@ return { return count end - -- Wait for SHOP state after state transition completes + -- Helper: check if the cash_out_button UI exists, meaning all scoring + -- rows from add_round_eval_row have finished (it's created last). + local function scoring_complete() + if not G.round_eval then return false end + for _, b in ipairs(G.I.UIBOX) do + if b:get_UIE_by_ID("cash_out_button") then return true end + end + return false + end + + -- Wait for scoring events to finish before triggering cash out. + -- When the bot's play() times out (e.g. win overlay pause) and the + -- Python side re-polls then sends cash_out, scoring-row events from + -- add_round_eval_row may still be in-flight. Calling G.FUNCS.cash_out + -- immediately nils G.round_eval and crashes at common_events.lua:1148. G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, func = function() - local done = false - 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") - send_response(BB_GAMESTATE.get_gamestate()) + if not scoring_complete() then return false end + + sendDebugMessage("cash_out() - scoring complete, triggering cash out", "BB.ENDPOINTS") + G.FUNCS.cash_out({ config = {} }) + + -- Wait for SHOP state after the cash-out transition completes + G.E_MANAGER:add_event(Event({ + trigger = "condition", + blocking = false, + func = function() + local done = false + 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") + send_response(BB_GAMESTATE.get_gamestate()) + return done + end + end return done - end - end - return done + end, + })) + return true end, })) end, diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 1cc7d253..22d2f6e7 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. Play cards using `play` instead.", + message = "No discards left", 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. Provide fewer card indices.", + message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/highlight.lua b/src/lua/endpoints/highlight.lua new file mode 100644 index 00000000..7f98b235 --- /dev/null +++ b/src/lua/endpoints/highlight.lua @@ -0,0 +1,50 @@ +-- src/lua/endpoints/highlight.lua + +-- ========================================================================== +-- Highlight Endpoint Params +-- ========================================================================== + +---@class Request.Endpoint.Highlight.Params +---@field card integer 0-based index of card to toggle highlight + +-- ========================================================================== +-- Highlight Endpoint +-- ========================================================================== + +---@type Endpoint +return { + + name = "highlight", + + description = "Toggle highlight on a single card in the hand", + + schema = { + card = { + type = "integer", + required = true, + description = "0-based index of the card to toggle highlight", + }, + }, + + requires_state = { G.STATES.SELECTING_HAND }, + + ---@param args Request.Endpoint.Highlight.Params + ---@param send_response fun(response: Response.Endpoint) + execute = function(args, send_response) + sendDebugMessage("Init highlight()", "BB.ENDPOINTS") + + if not G.hand.cards[args.card + 1] then + send_response({ + message = "Invalid card index: " .. args.card, + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + + G.hand.cards[args.card + 1]:click() + + sendDebugMessage("Return highlight() - toggled card " .. args.card, "BB.ENDPOINTS") + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + end, +} diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index 08bff0ee..f5d1557a 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -126,7 +126,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. Use `buy` with `pack` parameter to buy and open a pack.", + message = "No pack is currently open", name = BB_ERROR_NAMES.INVALID_STATE, }) return @@ -157,8 +157,7 @@ return { message = "Cannot select joker, joker slots are full. Current: " .. joker_count .. ", Limit: " - .. joker_limit - .. ". Sell a joker using `sell` to free a slot.", + .. joker_limit, name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -174,11 +173,7 @@ 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. Ensure you have enough jokers before selecting this card.", - card_key, - joker_count - ), + message = string.format("Card '%s' requires at least 1 joker. Current: %d", card_key, joker_count), name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -191,14 +186,14 @@ return { local msg if req.min == req.max then msg = string.format( - "Card '%s' requires exactly %d target card(s). Provided: %d. Ensure you have the required targets before selecting.", + "Card '%s' requires exactly %d target card(s). Provided: %d", card_key, req.min, target_count ) else msg = string.format( - "Card '%s' requires %d-%d target card(s). Provided: %d. Ensure you have the required targets before selecting.", + "Card '%s' requires %d-%d target card(s). Provided: %d", card_key, req.min, req.max, @@ -290,6 +285,7 @@ return { send_response(BB_GAMESTATE.get_gamestate()) return true end + end return false end, diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 94a5ee28..2c63b56f 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -48,7 +48,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ - message = "You can only play " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.", + message = "You can only play " .. G.hand.config.highlighted_limit .. " cards", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 12451eff..31ef538b 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, G.STATES.SMODS_BOOSTER_OPENED }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, ---@param args Request.Endpoint.Sell.Params ---@param send_response fun(response: Response.Endpoint) @@ -55,22 +55,6 @@ 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 @@ -156,11 +140,7 @@ return { local state_stable = G.STATE_COMPLETE == true -- 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 - ) + local valid_state = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND) -- Note: card count is NOT checked here — some jokers (e.g. Invisible Joker) -- spawn a replacement on sell, leaving count unchanged. card_gone + money_increased diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index d92eb7de..a95bf321 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -12,7 +12,8 @@ ---@field hands integer? New number of hands left number ---@field discards integer? New number of discards left number ---@field shop boolean? Re-stock shop with new items ----@field blind string? Boss blind key (e.g. "bl_flint") -- sets the upcoming boss blind +---@field blind string? Boss blind key (e.g. "bl_flint") — sets the upcoming boss blind +---@field debuff boolean? Re-apply blind debuffs to all hand cards (useful after add()) -- ========================================================================== -- Set Endpoint @@ -64,7 +65,12 @@ return { blind = { type = "string", required = false, - description = "Boss blind key (e.g. 'bl_flint') -- sets the upcoming boss blind", + description = "Boss blind key (e.g. 'bl_flint') — sets the upcoming boss blind", + }, + debuff = { + type = "boolean", + required = false, + description = "Re-apply blind debuffs to all hand cards (call after add() during a boss blind)", }, }, @@ -94,6 +100,7 @@ return { and args.discards == nil and args.shop == nil and args.blind == nil + and args.debuff == nil then send_response({ message = "Must provide at least one field to set", @@ -236,6 +243,23 @@ return { G:update_shop() end + -- Re-apply blind debuffs to hand cards + if args.debuff then + if G.STATE ~= G.STATES.SELECTING_HAND then + send_response({ + message = "Can only apply debuffs in SELECTING_HAND state", + name = BB_ERROR_NAMES.NOT_ALLOWED, + }) + return + end + if G.GAME.blind and G.GAME.blind.debuff_card then + for _, card in ipairs(G.hand.cards) do + G.GAME.blind:debuff_card(card) + end + sendDebugMessage("Re-applied blind debuffs to " .. #G.hand.cards .. " hand cards", "BB.ENDPOINTS") + end + end + G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 5fea1c77..0e684bed 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. Use `select` to select and play the boss blind.", + message = "Cannot skip Boss blind", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index aef07852..e9bb9d29 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -153,10 +153,12 @@ return { ) local ok, err = pcall(G.FUNCS.start_run, nil, run_params) if not ok then + sendDebugMessage("start_run failed: " .. tostring(err) .. " — retrying after delete_run", "BB.ENDPOINTS") -- Clean up stale state and retry pcall(function() G:delete_run() end) local ok2, err2 = pcall(G.FUNCS.start_run, nil, run_params) if not ok2 then + sendDebugMessage("start_run retry also failed: " .. tostring(err2), "BB.ENDPOINTS") send_response({ message = "Failed to start run: " .. tostring(err2), name = BB_ERROR_NAMES.INTERNAL_ERROR, diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index dedba80c..7801ed37 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,9 +72,7 @@ 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. Provide target cards via the `cards` parameter.", + message = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -102,7 +100,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). Provide the correct number of cards via the `cards` parameter.", + "Consumable '%s' requires exactly %d card%s (provided: %d)", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -117,7 +115,7 @@ return { if card_count < min_cards then send_response({ message = string.format( - "Consumable '%s' requires at least %d card%s (provided: %d). Provide more cards via the `cards` parameter.", + "Consumable '%s' requires at least %d card%s (provided: %d)", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -131,7 +129,7 @@ return { if card_count > max_cards then send_response({ message = string.format( - "Consumable '%s' requires at most %d card%s (provided: %d). Provide fewer cards via the `cards` parameter.", + "Consumable '%s' requires at most %d card%s (provided: %d)", consumable_card.ability.name, max_cards, max_cards == 1 and "" or "s", @@ -178,9 +176,7 @@ 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. Use `sell` or `use` to free up space.", + message = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index f236eff9..3d563de0 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -411,29 +411,3 @@ ---| "UPCOMING" # Future blind ---| "DEFEATED" # Previously defeated blind ---| "SKIPPED" # Previously skipped blind - ----@alias Tag.Key ----| "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: 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) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 837aeb36..5d9d29f6 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -591,7 +591,7 @@ local function extract_round_info() -- Ancient Joker's current rotating suit if G.GAME.current_round.ancient_card and G.GAME.current_round.ancient_card.suit then local suit = G.GAME.current_round.ancient_card.suit - local suit_map = { Hearts = "H", Diamonds = "D", Clubs = "C", Spades = "S" } + local suit_map = {Spades = "S", Hearts = "H", Clubs = "C", Diamonds = "D"} round.ancient_suit = suit_map[suit] or suit end @@ -642,88 +642,6 @@ 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} @@ -779,29 +697,6 @@ 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") @@ -832,7 +727,8 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag = nil, --[[@type Tag?]] + tag_name = "", + tag_effect = "", }, big = { type = "BIG", @@ -840,7 +736,8 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag = nil, --[[@type Tag?]] + tag_name = "", + tag_effect = "", }, boss = { type = "BOSS", @@ -848,7 +745,8 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag = nil, --[[@type Tag?]] + tag_name = "", + tag_effect = "", }, } @@ -886,11 +784,8 @@ 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 = { - key = small_tag_key, - name = tag_info.name, - effect = tag_info.effect, - } + blinds.small.tag_name = tag_info.name + blinds.small.tag_effect = tag_info.effect end end @@ -913,11 +808,8 @@ 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 = { - key = big_tag_key, - name = tag_info.name, - effect = tag_info.effect, - } + blinds.big.tag_name = tag_info.name + blinds.big.tag_effect = tag_info.effect end end @@ -941,7 +833,7 @@ function gamestate.get_blinds_info() blinds.boss.score = math.floor(base_amount * 2 * ante_scaling) end - -- Boss blind has no tags (tag remains nil) + -- Boss blind has no tags (tag_name and tag_effect remain empty strings) return blinds end @@ -992,18 +884,16 @@ function gamestate.get_gamestate() -- Used vouchers (table) if G.GAME.used_vouchers then local used_vouchers = {} - for voucher_name, _ in pairs(G.GAME.used_vouchers) do - used_vouchers[voucher_name] = get_voucher_effect(voucher_name) + 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 end 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) @@ -1080,8 +970,8 @@ end ---Auto-dismiss the "YOU WIN" overlay to continue into endless mode. ---Called from love.update so it works even when the game is paused. ----Two-phase: first frame dismisses the overlay, subsequent frame confirms ----it's gone before setting win_overlay_dismissed (which unblocks play.lua). +---Two-phase: dismiss first, then confirm removal on a later frame so the +---game has time to fully process the overlay removal before the bot resumes. function gamestate.check_win_overlay() if gamestate.win_overlay_dismissed then return end if not G.GAME or not G.GAME.won then return end @@ -1089,15 +979,17 @@ function gamestate.check_win_overlay() -- Phase 2: overlay was dismissed on a previous frame, confirm it's gone if gamestate.win_overlay_dismissing then if not G.OVERLAY_MENU then + sendDebugMessage("check_win_overlay() - overlay confirmed gone, resuming", "BB.GAMESTATE") gamestate.win_overlay_dismissed = true end return end -- Phase 1: overlay is visible, dismiss it now - if not G.OVERLAY_MENU then return end + if not G.OVERLAY_MENU then return end -- not visible yet + sendDebugMessage("check_win_overlay() - dismissing win overlay for endless mode", "BB.GAMESTATE") G.FUNCS.exit_overlay_menu() - gamestate.on_game_over = nil + gamestate.on_game_over = nil -- prevent GAME_OVER callback from firing gamestate.win_overlay_dismissing = true end diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index aea1d7cc..23b32855 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -3,7 +3,7 @@ "info": { "title": "BalatroBot API", "description": "JSON-RPC 2.0 API for Balatro bot development. This API allows external clients to control the Balatro game, query game state, and execute actions through an HTTP server.", - "version": "1.4.1", + "version": "1.4.0", "license": { "name": "MIT" } @@ -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, pack, 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, 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_*), packs (p_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", + "description": "Card key. Format: jokers (j_*), consumables (c_*), vouchers (v_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", "required": true, "schema": { "$ref": "#/components/schemas/CardKey" @@ -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. 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.", + "description": "Sell a joker or consumable from player inventory. Must provide exactly one of: joker or consumable.", "tags": [ { "$ref": "#/components/tags/shop" @@ -614,9 +614,6 @@ { "$ref": "#/components/errors/BadRequest" }, - { - "$ref": "#/components/errors/InvalidState" - }, { "$ref": "#/components/errors/NotAllowed" } @@ -916,13 +913,6 @@ "type": "string" } }, - "tags": { - "type": "array", - "description": "Accumulated tags owned by the player", - "items": { - "$ref": "#/components/schemas/Tag" - } - }, "hands": { "type": "object", "description": "Poker hands information", @@ -1063,29 +1053,6 @@ } } }, - "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", @@ -1108,9 +1075,13 @@ "type": "integer", "description": "Score requirement to beat this blind" }, - "tag": { - "$ref": "#/components/schemas/Tag", - "description": "Tag associated with this blind (Small/Big only)" + "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)" } }, "required": [ diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 3f2f3854..53f43b13 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -17,7 +17,6 @@ ---@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 @@ -48,18 +47,14 @@ ---@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 Tag? Tag associated with this blind (Small/Big only) +---@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) ---@class Area ---@field count integer Current number of cards in this area diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index f482c64a..fe11a58b 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_*), pack (p_*), or playing card (SUIT_RANK)", + "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", ) def test_invalid_key_known_format(self, client: httpx.Client) -> None: diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 82e08189..5aaa6081 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. Use `reroll` to restock the shop.", + "No jokers/consumables/cards in the shop. 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. Sell a joker using `sell` to free a slot.", + "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", ) 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. Use `use` to activate a consumable or `sell` to remove one.", + "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", ) 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. Use `next_round` to advance to the next blind and restock the shop.", + "No packs to open", ) def test_buy_joker_success(self, client: httpx.Client) -> None: diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index a4ba0536..e35af2ba 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -3,7 +3,6 @@ import re import httpx -import pytest from tests.lua.conftest import api, assert_gamestate_response, load_fixture @@ -148,28 +147,24 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: "name": "Small Blind", "effect": "", "score": 300, - "tag": { - "key": "tag_polychrome", - "name": "Polychrome Tag", - "effect": "Next base edition shop Joker is free and becomes Polychrome", - }, + "tag_effect": "Next base edition shop Joker is free and becomes Polychrome", + "tag_name": "Polychrome Tag", }, "big": { - "type": "BIG", - "name": "Big Blind", "effect": "", + "name": "Big Blind", "score": 450, - "tag": { - "key": "tag_investment", - "name": "Investment Tag", - "effect": "After defeating the Boss Blind, gain $25", - }, + "tag_effect": "After defeating the Boss Blind, gain $25", + "tag_name": "Investment Tag", + "type": "BIG", }, "boss": { - "type": "BOSS", - "name": "The Manacle", "effect": "-1 Hand Size", + "name": "The Manacle", "score": 600, + "tag_effect": "", + "tag_name": "", + "type": "BOSS", }, } actual_blinds = { @@ -821,167 +816,6 @@ 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-SHOP", - ) - 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 - - -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_pack.py b/tests/lua/endpoints/test_pack.py index 0e60555b..1ff2a514 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -158,35 +158,6 @@ 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( diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 1ca8179d..5a89edc8 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -20,12 +20,10 @@ 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.""" @@ -34,14 +32,10 @@ def test_skip_big_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["big"]["status"] == "SELECT" - 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 {"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,10 +44,6 @@ 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_polychrome", "tag_investment"} == set( - k["key"] for k in gamestate["tags"] - ) assert_error_response( api(client, "skip", {}), "NOT_ALLOWED", diff --git a/uv.lock b/uv.lock index dfaa6cdc..e64f17c2 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "balatrobot" -version = "1.4.1" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 2151d53ecb36e735410ad8968dd653fe4749e449 Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sun, 12 Apr 2026 09:34:20 -0400 Subject: [PATCH 16/23] =?UTF-8?q?Revert=20cash=5Fout=20scoring=5Fcomplete?= =?UTF-8?q?=20guard=20=E2=80=94=20was=20masking=20a=20bot-side=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scoring_complete() guard (wait for cash_out_button UI before calling G.FUNCS.cash_out) was added to prevent a crash when cash_out was called while scoring rows were still in-flight. Root cause turned out to be a 5-second timeout override in the Python bot (JackPotts bot.py) that fired on every ante 8 play at normal animation speed. The bot would abandon requests mid-scoring, re-poll, then send cash_out while the previous play was still animating — creating the race. With the aggressive timeout removed from bot.py (default 30s is plenty), cash_out is only called after scoring completes naturally. The upstream implementation works correctly without this guard. Co-Authored-By: Claude Opus 4.6 --- src/lua/endpoints/cash_out.lua | 48 ++++++++-------------------------- 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index adac2762..03e7ee64 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -25,6 +25,7 @@ return { ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) sendDebugMessage("Init cash_out()", "BB.ENDPOINTS") + G.FUNCS.cash_out({ config = {} }) local num_items = function(area) local count = 0 @@ -38,48 +39,21 @@ return { return count end - -- Helper: check if the cash_out_button UI exists, meaning all scoring - -- rows from add_round_eval_row have finished (it's created last). - local function scoring_complete() - if not G.round_eval then return false end - for _, b in ipairs(G.I.UIBOX) do - if b:get_UIE_by_ID("cash_out_button") then return true end - end - return false - end - - -- Wait for scoring events to finish before triggering cash out. - -- When the bot's play() times out (e.g. win overlay pause) and the - -- Python side re-polls then sends cash_out, scoring-row events from - -- add_round_eval_row may still be in-flight. Calling G.FUNCS.cash_out - -- immediately nils G.round_eval and crashes at common_events.lua:1148. + -- Wait for SHOP state after state transition completes G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, func = function() - if not scoring_complete() then return false end - - sendDebugMessage("cash_out() - scoring complete, triggering cash out", "BB.ENDPOINTS") - G.FUNCS.cash_out({ config = {} }) - - -- Wait for SHOP state after the cash-out transition completes - G.E_MANAGER:add_event(Event({ - trigger = "condition", - blocking = false, - func = function() - local done = false - 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") - send_response(BB_GAMESTATE.get_gamestate()) - return done - end - end + local done = false + 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") + send_response(BB_GAMESTATE.get_gamestate()) return done - end, - })) - return true + end + end + return done end, })) end, From 34d0c16f41b5950d56d8581bbe2a5531c62a20ce Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sat, 18 Apr 2026 10:15:39 -0400 Subject: [PATCH 17/23] feat(gamestate): expose idol_card in round info Balatro stores The Idol's target card (rank+suit) on G.GAME.current_round.idol_card, not on the joker itself, so clients previously had no way to compute The Idol's X2 Mult accurately. Surface it alongside ancient_suit using the same rank/suit enum normalization. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lua/utils/gamestate.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 5d9d29f6..57d6e6e6 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -595,6 +595,18 @@ local function extract_round_info() round.ancient_suit = suit_map[suit] or suit end + -- The Idol: target rank+suit that grants X2 Mult, rerolled each round + if G.GAME.current_round.idol_card + and G.GAME.current_round.idol_card.rank + and G.GAME.current_round.idol_card.suit + then + local suit_map = { Spades = "S", Hearts = "H", Clubs = "C", Diamonds = "D" } + round.idol_card = { + rank = convert_rank_to_enum(G.GAME.current_round.idol_card.rank) or G.GAME.current_round.idol_card.rank, + suit = suit_map[G.GAME.current_round.idol_card.suit] or G.GAME.current_round.idol_card.suit, + } + end + return round end From c2116ba434f8012b3ddd264e70b53b15140f8845 Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sat, 18 Apr 2026 18:24:36 -0400 Subject: [PATCH 18/23] fix(seed): real entropy for auto-generated seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balatro's generate_starting_seed() derives randomness from the mouse cursor position and hover time. In headless mode the cursor is stationary, causing consecutive auto-seeded runs to collide (observed up to ~60% duplicate seeds across small batches). Swap the cursor expression for os.time() + love.timer.getTime() + love.math.random() via two Lovely pattern patches. random_string() is untouched — only its numeric seed input changes. Co-Authored-By: Claude Opus 4.7 --- lovely/seed.toml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 lovely/seed.toml diff --git a/lovely/seed.toml b/lovely/seed.toml new file mode 100644 index 00000000..aa29c454 --- /dev/null +++ b/lovely/seed.toml @@ -0,0 +1,32 @@ +# Replace Balatro's cursor-based auto-seed generator with real entropy. +# +# Balatro's generate_starting_seed() (functions/misc_functions.lua) derives +# randomness from the mouse cursor position and hover timestamp. In headless +# mode the cursor is stationary, so consecutive auto-seeded runs collide. +# +# Swap the cursor expression for os.time() + love.timer.getTime() (sub-µs +# monotonic clock) + love.math.random() (seeded from OS entropy at LÖVE +# startup). random_string() stays intact — only its numeric seed changes. + +[manifest] +version = "1.0.0" +priority = 0 +dump_lua = true + +# Legendary-rerolling loop (stake >= #Stake pool). +[[patches]] +[patches.pattern] +target = "functions/misc_functions.lua" +pattern = "seed_found = random_string(8, extra_num + G.CONTROLLER.cursor_hover.T.x*0.33411983 + G.CONTROLLER.cursor_hover.T.y*0.874146 + 0.412311010*G.CONTROLLER.cursor_hover.time)" +position = "at" +match_indent = true +payload = "seed_found = random_string(8, extra_num + os.time() + (love.timer and love.timer.getTime() or 0) * 1000000 + love.math.random() * 2147483647)" + +# Default path — ordinary new run. +[[patches]] +[patches.pattern] +target = "functions/misc_functions.lua" +pattern = "return random_string(8, G.CONTROLLER.cursor_hover.T.x*0.33411983 + G.CONTROLLER.cursor_hover.T.y*0.874146 + 0.412311010*G.CONTROLLER.cursor_hover.time)" +position = "at" +match_indent = true +payload = "return random_string(8, os.time() + (love.timer and love.timer.getTime() or 0) * 1000000 + love.math.random() * 2147483647)" From 46228866b9063faf2c7c5c833dab34907b30084d Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sun, 19 Apr 2026 18:50:39 -0400 Subject: [PATCH 19/23] Add earnings tracker for per-source money attribution Hooks three sites to record every dollar earned during a run, attributed to its source: 1. add_round_eval_row - jokers (calc_dollar_bonus), tags, interest, hand/discard money, blind reward. 2. Card:get_p_dollars - per-played-card scoring income (Lucky cards, Gold seal, Gold enhancement). 3. card_eval_status_text(card, 'dollars', ...) filtered to jokers - mid-round triggers (Faceless, Rough Gem, Trading Card, etc.). Entries accumulate on G.GAME.jackpotts_earnings.entries and are exposed by the gamestate serializer as state.earnings. Lazy init handles new games naturally (G.GAME is recreated, so no explicit reset needed). Co-Authored-By: Claude Opus 4.7 --- balatrobot.lua | 2 + src/lua/utils/earnings.lua | 157 ++++++++++++++++++++++++++++++++++++ src/lua/utils/gamestate.lua | 5 ++ 3 files changed, 164 insertions(+) create mode 100644 src/lua/utils/earnings.lua diff --git a/balatrobot.lua b/balatrobot.lua index 4e0aaa9c..4119e3e0 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -57,6 +57,8 @@ assert(SMODS.load_file("src/lua/core/dispatcher.lua"))() -- define BB_DISPATCHER -- Load gamestate and errors utilities BB_GAMESTATE = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() assert(SMODS.load_file("src/lua/utils/errors.lua"))() +BB_EARNINGS = assert(SMODS.load_file("src/lua/utils/earnings.lua"))() +BB_EARNINGS.install() -- Initialize Server local server_success = BB_SERVER.init() diff --git a/src/lua/utils/earnings.lua b/src/lua/utils/earnings.lua new file mode 100644 index 00000000..c77923cd --- /dev/null +++ b/src/lua/utils/earnings.lua @@ -0,0 +1,157 @@ +---Per-game money earnings tracker. +--- +---Captures every dollar earned during a run, attributed to its source +---(joker / tag / interest / hands / discards / blind / playing-card). +---Stored on G.GAME.jackpotts_earnings for the gamestate serializer to expose. +--- +---Hooks three sites: +--- 1. add_round_eval_row — round-end income (jokers via calc_dollar_bonus, +--- tags, interest, hand/discard money, blind reward). +--- 2. Card:get_p_dollars — per-played-card income (Lucky, Gold seal, Gold +--- enhancement) accumulated during scoring. +--- 3. card_eval_status_text(card, 'dollars', amt) — mid-round joker +--- triggers (Faceless, Rough Gem, Trading Card, etc.). Filtered to +--- joker-set cards to avoid double-counting #2. + +local earnings = {} + +local function ensure_store() + if not G or not G.GAME then return nil end + if not G.GAME.jackpotts_earnings then + G.GAME.jackpotts_earnings = { entries = {}, next_id = 1 } + end + return G.GAME.jackpotts_earnings +end + +local function current_round_num() + if G and G.GAME and G.GAME.round then return G.GAME.round end + return 0 +end + +local function current_ante() + if G and G.GAME and G.GAME.round_resets and G.GAME.round_resets.ante then + return G.GAME.round_resets.ante + end + return 0 +end + +local function record(entry) + local store = ensure_store() + if not store then return end + entry.id = store.next_id + entry.round = entry.round or current_round_num() + entry.ante = entry.ante or current_ante() + store.next_id = store.next_id + 1 + table.insert(store.entries, entry) +end + +local function joker_key(card) + if card and card.config and card.config.center and card.config.center.key then + return card.config.center.key + end + return nil +end + +local function tag_key(tag) + if tag and tag.key then return tag.key end + return nil +end + +---Map add_round_eval_row's `name` field to a stable source category. +---Names like "joker1", "joker2", "tag1", "tag2" become "joker"/"tag"; +---"hands"/"discards"/"interest"/"blind1" pass through trimmed. +local function classify_eval_row(name) + if not name then return "unknown" end + if name:match("^joker%d*$") then return "joker" end + if name:match("^tag%d*$") then return "tag" end + if name == "interest" then return "interest" end + if name == "hands" then return "hands" end + if name == "discards" then return "discards" end + if name:match("^blind") then return "blind" end + return name +end + +function earnings.install() + if earnings._installed then return end + + -- 1. add_round_eval_row — round-end income + if type(add_round_eval_row) == "function" then + local _orig = add_round_eval_row + add_round_eval_row = function(args) ---@diagnostic disable-line: duplicate-set-field + args = args or {} + local dollars = args.dollars or 0 + if dollars ~= 0 and not args.saved then + record({ + source = classify_eval_row(args.name), + raw_name = args.name, + dollars = dollars, + joker_key = joker_key(args.card), + tag_key = tag_key(args.tag), + phase = "round_eval", + }) + end + return _orig(args) + end + end + + -- 2. Card:get_p_dollars — per-card scoring income (Lucky, Gold seal, + -- Gold enhancement). Hook returns the dollar amount. + if Card and Card.get_p_dollars then + local _orig = Card.get_p_dollars + Card.get_p_dollars = function(self) ---@diagnostic disable-line: duplicate-set-field + local ret = _orig(self) + if ret and ret ~= 0 then + local enh = self.config and self.config.center and self.config.center.key + local seal = self.seal + local source = "card" + if self.lucky_trigger then + source = "lucky" + elseif seal == "Gold" then + source = "gold_seal" + elseif enh == "m_gold" then + source = "gold_enhancement" + end + record({ + source = source, + dollars = ret, + enhancement = enh, + seal = seal, + rank = self.base and self.base.value, + suit = self.base and self.base.suit, + phase = "scoring", + }) + end + return ret + end + end + + -- 3. card_eval_status_text with eval_type == 'dollars' on jokers — + -- mid-round joker-triggered income (Faceless, Rough Gem, Trading + -- Card, etc.). Filter to jokers to avoid double-counting #2. + if type(card_eval_status_text) == "function" then + local _orig = card_eval_status_text + card_eval_status_text = function(card, eval_type, amt, percent, dir, extra) ---@diagnostic disable-line: duplicate-set-field + if eval_type == "dollars" and amt and amt ~= 0 + and card and card.ability and card.ability.set == "Joker" then + record({ + source = "joker_trigger", + dollars = amt, + joker_key = joker_key(card), + phase = "play", + }) + end + return _orig(card, eval_type, amt, percent, dir, extra) + end + end + + earnings._installed = true +end + +---Reset accumulator (called at game start). Safe to call when G.GAME absent. +function earnings.reset() + if G and G.GAME then + G.GAME.jackpotts_earnings = { entries = {}, next_id = 1 } + end +end + +return earnings diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 57d6e6e6..df6f52f8 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -916,6 +916,11 @@ function gamestate.get_gamestate() -- Blinds info state_data.blinds = gamestate.get_blinds_info() + + -- JackPotts earnings tracker (per-game money attribution) + if G.GAME.jackpotts_earnings and G.GAME.jackpotts_earnings.entries then + state_data.earnings = G.GAME.jackpotts_earnings.entries + end end -- Always available areas From 92051f90d791b16407a01418d1d4ad1f1ea80450 Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sun, 19 Apr 2026 19:33:37 -0400 Subject: [PATCH 20/23] feat(gamestate): expose held tags Adds state.tags array (key, name, ante) so consumers can track tag acquisition and current holdings without diff-inferring. Co-Authored-By: Claude Opus 4.7 --- src/lua/utils/gamestate.lua | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index df6f52f8..7572cced 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -921,6 +921,22 @@ function gamestate.get_gamestate() if G.GAME.jackpotts_earnings and G.GAME.jackpotts_earnings.entries then state_data.earnings = G.GAME.jackpotts_earnings.entries end + + -- Currently held tags (Investment, Handy, Top-up, Speed, Garbage, etc.). + -- Each entry: {key, name, ante (acquired)}. + if G.GAME.tags and #G.GAME.tags > 0 then + local tags_out = {} + for _, tag in ipairs(G.GAME.tags) do + local t_key = tag.key or "" + local t_info = (G.P_TAGS and G.P_TAGS[t_key]) or {} + table.insert(tags_out, { + key = t_key, + name = t_info.name or t_key, + ante = tag.ante, + }) + end + state_data.tags = tags_out + end end -- Always available areas From b39f318f6fc3dcefac5c0f262ba40a4b28e46e3b Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Sun, 19 Apr 2026 20:02:57 -0400 Subject: [PATCH 21/23] fix(earnings): drop bottom-row double-count, hook calculate_joker for mid-round $ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the earnings tracker: 1. add_round_eval_row({name='bottom', ...}) is the round-summary total row that sums every joker/blind/interest/hands row above it. We were recording it as income, so every other source was double-counted. Filter on name~='bottom'. 2. The card_eval_status_text(card,'dollars',amt) hook only ever fired for rental fees — Faceless, Rough Gem, Business, Reserved Parking etc. all call ease_dollars() directly inside their calculate function, never routing through status_text. Replaced with a Card:calculate_joker wrapper that maintains a joker stack, plus an ease_dollars wrapper that attributes the $ to whichever joker is on top. ease_dollars outside any joker scope (rentals, shop, blind reward) is ignored. Co-Authored-By: Claude Opus 4.7 --- src/lua/utils/earnings.lua | 58 ++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/lua/utils/earnings.lua b/src/lua/utils/earnings.lua index c77923cd..ba922ef1 100644 --- a/src/lua/utils/earnings.lua +++ b/src/lua/utils/earnings.lua @@ -7,11 +7,17 @@ ---Hooks three sites: --- 1. add_round_eval_row — round-end income (jokers via calc_dollar_bonus, --- tags, interest, hand/discard money, blind reward). +--- Skips name=='bottom' which is the Balatro round-summary total +--- (would double-count every other row). --- 2. Card:get_p_dollars — per-played-card income (Lucky, Gold seal, Gold --- enhancement) accumulated during scoring. ---- 3. card_eval_status_text(card, 'dollars', amt) — mid-round joker ---- triggers (Faceless, Rough Gem, Trading Card, etc.). Filtered to ---- joker-set cards to avoid double-counting #2. +--- 3. Card:calculate_joker + ease_dollars — mid-round joker triggers +--- (Faceless, Rough Gem, Business, Reserved Parking, etc.). The +--- calculate_joker wrapper pushes the joker onto a stack before its +--- effect runs; the ease_dollars wrapper attributes the $ to whatever +--- joker is on top of the stack. Outside any joker scope, ease_dollars +--- is ignored (the $ is captured by another path or is non-earnings, +--- e.g. shop sells/buys, rentals). local earnings = {} @@ -75,12 +81,15 @@ function earnings.install() if earnings._installed then return end -- 1. add_round_eval_row — round-end income + -- Skip name=='bottom' (the round-summary total row that sums all the + -- joker/blind/interest/hands rows above it — recording it would + -- double-count every other source). if type(add_round_eval_row) == "function" then local _orig = add_round_eval_row add_round_eval_row = function(args) ---@diagnostic disable-line: duplicate-set-field args = args or {} local dollars = args.dollars or 0 - if dollars ~= 0 and not args.saved then + if dollars ~= 0 and not args.saved and args.name ~= "bottom" then record({ source = classify_eval_row(args.name), raw_name = args.name, @@ -125,22 +134,41 @@ function earnings.install() end end - -- 3. card_eval_status_text with eval_type == 'dollars' on jokers — - -- mid-round joker-triggered income (Faceless, Rough Gem, Trading - -- Card, etc.). Filter to jokers to avoid double-counting #2. - if type(card_eval_status_text) == "function" then - local _orig = card_eval_status_text - card_eval_status_text = function(card, eval_type, amt, percent, dir, extra) ---@diagnostic disable-line: duplicate-set-field - if eval_type == "dollars" and amt and amt ~= 0 - and card and card.ability and card.ability.set == "Joker" then + -- 3. Card:calculate_joker + ease_dollars — mid-round joker triggers. + -- Faceless, Rough Gem, Business, Reserved Parking, Trading Card, + -- To the Moon-style payouts all call ease_dollars() directly inside + -- their joker calculate function — they do NOT route through + -- card_eval_status_text. To attribute the $ to the right joker we + -- push self onto a stack at the start of calculate_joker and pop + -- when it returns; ease_dollars then credits the joker on top of + -- the stack. ease_dollars calls outside any joker scope (rentals, + -- shop sells/buys, blind reward) are ignored — they're either + -- captured elsewhere (round_eval path) or aren't earnings. + earnings._joker_stack = {} + if Card and Card.calculate_joker then + local _orig = Card.calculate_joker + Card.calculate_joker = function(self, context) ---@diagnostic disable-line: duplicate-set-field + table.insert(earnings._joker_stack, self) + local ok, a, b = pcall(_orig, self, context) + table.remove(earnings._joker_stack) + if not ok then error(a) end + return a, b + end + end + if type(ease_dollars) == "function" then + local _orig = ease_dollars + ease_dollars = function(mod, instant) ---@diagnostic disable-line: duplicate-set-field + local stack = earnings._joker_stack + local top = stack[#stack] + if top and mod and mod ~= 0 then record({ source = "joker_trigger", - dollars = amt, - joker_key = joker_key(card), + dollars = mod, + joker_key = joker_key(top), phase = "play", }) end - return _orig(card, eval_type, amt, percent, dir, extra) + return _orig(mod, instant) end end From 2524d80b890ad399ba489e88b605285159530a5e Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Tue, 21 Apr 2026 18:17:37 -0400 Subject: [PATCH 22/23] fix(pack): let skip bypass guard and pcall-wrap use_card to escape wedges The pack endpoint's selection_in_progress guard could latch forever in two scenarios: (1) G.FUNCS.use_card errored before firing the completion event (e.g. Hex with only editioned jokers, Ankh with zero jokers), and (2) the skip branch tried to clear the guard at line 299 but was unreachable since the guard check at line 118 fired first and returned NOT_ALLOWED. Skip now bypasses the guard (wedge-recovery path), and use_card is wrapped in pcall so errors release the guard and surface to the bot instead of leaving the pack unrecoverable. --- src/lua/endpoints/pack.lua | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index f5d1557a..1d7516b9 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -114,8 +114,13 @@ return { return end - -- Block re-entrant calls while a previous selection is processing - if selection_in_progress then + -- Block re-entrant calls while a previous selection is processing. + -- Skip bypasses the guard — it's the wedge-recovery path, and the skip + -- branch below clears selection_in_progress unconditionally before + -- calling skip_booster. Without this bypass a stuck guard (e.g. a + -- use_card error that never fires the completion event) wedges the + -- pack forever with no way for the bot to escape. + if selection_in_progress and not args.skip then send_response({ message = "Pack selection already in progress", name = BB_ERROR_NAMES.NOT_ALLOWED, @@ -253,7 +258,19 @@ return { local pack_choices_before = G.GAME.pack_choices or 0 selection_in_progress = true - G.FUNCS.use_card(btn) + -- Wrap use_card in pcall: if the game rejects the selection (e.g. Hex + -- with no editionless jokers, Ankh with no jokers), the completion + -- event never fires and the guard would latch on forever. pcall ensures + -- we surface the error and release the guard so the bot can recover. + local ok, err = pcall(G.FUNCS.use_card, btn) + if not ok then + selection_in_progress = false + send_response({ + message = "use_card failed: " .. tostring(err), + name = BB_ERROR_NAMES.INVALID_STATE, + }) + return true + end -- Wait for action to complete - check pack_choices to determine expected state G.E_MANAGER:add_event(Event({ From 6e434152c7761bef42b3ad3e8c4ce40951174856 Mon Sep 17 00:00:00 2001 From: DrLatBC Date: Tue, 21 Apr 2026 18:33:35 -0400 Subject: [PATCH 23/23] fix(pack): defer to Card:can_use_consumeable before use_card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pre-flight call to the game's own validation. Catches cases the endpoint-level schema checks miss — notably Ectoplasm/Hex with all jokers already editioned, which otherwise crashes the game in a delayed E_MANAGER event at card.lua:1734 (pseudorandom_element returns nil on the empty eligible_editionless_jokers, then :set_edition indexes a nil value). Wrapped in pcall so any nil-field errors in can_use_consumeable itself (e.g. eligibility tables not yet populated by Card:update) fall through rather than blocking legitimate picks. --- src/lua/endpoints/pack.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index 1d7516b9..e94d523d 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -257,6 +257,25 @@ return { local pack_choices_before = G.GAME.pack_choices or 0 + -- Pre-flight: defer to the game's own validation. Covers cases the + -- endpoint-level checks miss — e.g. Ectoplasm/Hex with zero editionless + -- jokers (card.lua:1798-1800), which otherwise crashes the game in a + -- delayed E_MANAGER event at card.lua:1734 trying to index a nil result + -- from pseudorandom_element({}). + if card.can_use_consumeable then + local usable_ok, usable = pcall(card.can_use_consumeable, card, true) + if usable_ok and usable == false then + send_response({ + message = string.format( + "Card '%s' cannot be used in current state (game rejected via can_use_consumeable)", + card_key or card.ability and card.ability.name or "unknown" + ), + name = BB_ERROR_NAMES.NOT_ALLOWED, + }) + return true + end + end + selection_in_progress = true -- Wrap use_card in pcall: if the game rejects the selection (e.g. Hex -- with no editionless jokers, Ankh with no jokers), the completion