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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions balatrobot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
]
}
4 changes: 4 additions & 0 deletions balatrobot.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,6 +74,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
Expand Down
55 changes: 4 additions & 51 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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:**

Expand Down Expand Up @@ -698,7 +698,6 @@ The complete game state returned by most methods.
"seed": "ABC123",
"won": false,
"used_vouchers": {},
"tags": [ ... ],
"hands": { ... },
"round": { ... },
"blinds": { ... },
Expand Down Expand Up @@ -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"
}
```

Expand Down Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions src/lua/endpoints/add.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 23 additions & 18 deletions src/lua/endpoints/buy.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -240,28 +238,35 @@ 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
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
Expand Down
6 changes: 2 additions & 4 deletions src/lua/endpoints/discard.lua
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ 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
end

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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/lua/endpoints/highlight.lua
Original file line number Diff line number Diff line change
@@ -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,
}
Loading