Skip to content
55 changes: 51 additions & 4 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.
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)

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

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

Expand Down Expand Up @@ -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.
Expand Down
15 changes: 6 additions & 9 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, 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",
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_*), 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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 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. 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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 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",
message = "No discards left. Play cards using `play` instead.",
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",
message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.",
name = BB_ERROR_NAMES.BAD_REQUEST,
})
return
Expand Down
15 changes: 10 additions & 5 deletions src/lua/endpoints/pack.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/lua/endpoints/play.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions src/lua/endpoints/sell.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/lua/endpoints/skip.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions src/lua/endpoints/use.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/lua/utils/enums.lua
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,29 @@
---| "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)
Loading