Skip to content

Commit 4be8c16

Browse files
committed
feat(lua): add buy_and_use shop endpoint
Add a dedicated `buy_and_use` endpoint that buys a shop consumable (Tarot/Planet/Spectral) and uses it immediately, never occupying a consumable slot. This mirrors Balatro's "Buy and Use" button and is the one shop action that still works when consumable slots are full — the motivating case for the issue. The endpoint faithfully replicates the game's behaviour rather than re-deriving it: - The pre-flight gate is exactly the game's visibility gate (G.FUNCS.can_buy_and_use): affordability -> BAD_REQUEST, can_use_consumeable -> NOT_ALLOWED. We never pre-call check_use, so we are never stricter than the game. - Ankh at full jokers is therefore a SUCCESS, not an error: the button is visible, money is deducted, and use_card's execution-time check_use then bails with no joker created. We report this honestly (money spent, joker count unchanged). - buy_from_shop is invoked via an ephemeral inline mock button ({config={id="buy_and_use", ref_table=card}}), matching the programmatic-call idiom the game itself uses, instead of aliasing the live on-screen buy button. - Completion detection is the union of the buy- and use-phase terminal conditions (shop decreased AND money deducted AND STATE==SHOP AND not locks.use), which is race-free for both the normal path and the Ankh noop. Registered in balatrobot.lua, added to the OpenRPC spec, exposed through the CLI Method enum (and its count assertion), and documented in api.md. Closes #209
1 parent e3a47d0 commit 4be8c16

6 files changed

Lines changed: 201 additions & 1 deletion

File tree

balatrobot.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ BB_ENDPOINTS = {
3535
"src/lua/endpoints/next_round.lua",
3636
"src/lua/endpoints/reroll.lua",
3737
"src/lua/endpoints/buy.lua",
38+
"src/lua/endpoints/buy_and_use.lua",
3839
"src/lua/endpoints/pack.lua",
3940
-- Rearrange endpoint
4041
"src/lua/endpoints/rearrange.lua",

docs/api.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,36 @@ curl -X POST http://127.0.0.1:12346 \
346346

347347
---
348348

349+
### `buy_and_use`
350+
351+
Buy a shop consumable (Tarot/Planet/Spectral) and use it immediately, without
352+
occupying a consumable slot. Mirrors Balatro's **"Buy and Use"** shop button.
353+
Unlike [`buy`](#buy), this never places the card into a consumable slot, so it
354+
works even when consumable slots are full.
355+
356+
**Parameters:**
357+
358+
| Name | Type | Required | Description |
359+
| ------ | ------- | -------- | ------------------------------------------- |
360+
| `card` | integer | Yes | 0-based index of shop consumable to buy+use |
361+
362+
**Returns:** [GameState](#gamestate-schema)
363+
364+
**Errors:** `BAD_REQUEST`, `NOT_ALLOWED`
365+
366+
**Required State:** `SHOP`
367+
368+
**Example:**
369+
370+
```bash
371+
# Buy and use the first shop consumable
372+
curl -X POST http://127.0.0.1:12346 \
373+
-H "Content-Type: application/json" \
374+
-d '{"jsonrpc": "2.0", "method": "buy_and_use", "params": {"card": 0}, "id": 1}'
375+
```
376+
377+
---
378+
349379
### `pack`
350380

351381
Select a card or skip a pack from an opened booster pack.

src/balatrobot/cli/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Method(StrEnum):
1717

1818
ADD = "add"
1919
BUY = "buy"
20+
BUY_AND_USE = "buy_and_use"
2021
CASH_OUT = "cash_out"
2122
DISCARD = "discard"
2223
GAMESTATE = "gamestate"

src/lua/endpoints/buy_and_use.lua

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
-- src/lua/endpoints/buy_and_use.lua
2+
3+
-- ==========================================================================
4+
-- Buy and Use Endpoint Params
5+
-- ==========================================================================
6+
7+
---@class Request.Endpoint.BuyAndUse.Params
8+
---@field card integer? 0-based index of shop consumable to buy and use
9+
10+
-- ==========================================================================
11+
-- Buy and Use Endpoint
12+
-- ==========================================================================
13+
14+
---@type Endpoint
15+
return {
16+
17+
name = "buy_and_use",
18+
19+
description = "Buy and use a consumable card from the shop in one step",
20+
21+
schema = {
22+
card = {
23+
type = "integer",
24+
required = false,
25+
description = "0-based index of shop consumable to buy and use",
26+
},
27+
},
28+
29+
requires_state = { G.STATES.SHOP },
30+
31+
---@param args Request.Endpoint.BuyAndUse.Params
32+
---@param send_response fun(response: Response.Endpoint)
33+
execute = function(args, send_response)
34+
sendDebugMessage("buy_and_use()", "BB.ENDPOINTS")
35+
local gamestate = BB_GAMESTATE.get_gamestate()
36+
37+
-- 1. Validate card param is provided
38+
if not args.card then
39+
send_response({
40+
message = "Invalid arguments. You must provide: card",
41+
name = BB_ERROR_NAMES.BAD_REQUEST,
42+
})
43+
return
44+
end
45+
46+
local area = gamestate.shop
47+
local pos = args.card + 1
48+
49+
-- 2. Validate shop has cards
50+
if #area.cards == 0 then
51+
send_response({
52+
message = "No consumables in the shop. Use `reroll` to restock the shop.",
53+
name = BB_ERROR_NAMES.BAD_REQUEST,
54+
})
55+
return
56+
end
57+
58+
-- 3. Validate card index is in range
59+
if not area.cards[pos] then
60+
send_response({
61+
message = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count,
62+
name = BB_ERROR_NAMES.BAD_REQUEST,
63+
})
64+
return
65+
end
66+
67+
local card = area.cards[pos]
68+
local live_card = G.shop_jokers.cards[pos]
69+
70+
-- 4. Affordability check (mirrors can_buy_and_use; handles Credit Card joker)
71+
local available_money = G.GAME.dollars - G.GAME.bankrupt_at
72+
if card.cost.buy > 0 and card.cost.buy > available_money then
73+
send_response({
74+
message = "Card is not affordable. Cost: " .. card.cost.buy .. ", Available money: " .. available_money,
75+
name = BB_ERROR_NAMES.BAD_REQUEST,
76+
})
77+
return
78+
end
79+
80+
-- 5. Feasibility gate. Mirrors G.FUNCS.can_buy_and_use, which the game only
81+
-- ever attaches to consumable shop cards. Card:can_use_consumeable()
82+
-- indexes ability.consumeable, so guard non-consumables (jokers/playing
83+
-- cards) for which the Buy-and-Use button never exists. (Per §2.3 we do
84+
-- NOT pre-call check_use here — the game calls it inside use_card.)
85+
local is_consumable = live_card.ability and live_card.ability.consumeable ~= nil
86+
if not is_consumable or not live_card:can_use_consumeable() then
87+
send_response({
88+
message = "Consumable '"
89+
.. (live_card.ability.name or card.label or "Unknown")
90+
.. "' cannot be buy-and-used at this time.",
91+
name = BB_ERROR_NAMES.NOT_ALLOWED,
92+
})
93+
return
94+
end
95+
96+
-- Capture initial state for completion detection
97+
local initial_shop_count = (G.shop_jokers and G.shop_jokers.config and G.shop_jokers.config.card_count or 0)
98+
local initial_money = gamestate.money
99+
100+
-- Log what we're buy-and-using
101+
local item_name = live_card.ability.name or card.label or "Unknown"
102+
sendInfoMessage(string.format("Buy-and-using '%s' for $%d", item_name, card.cost.buy), "BB.ENDPOINTS")
103+
104+
-- Programmatic mock-button invocation (ephemeral inline table; routes to
105+
-- the buy_and_use branch inside G.FUNCS.buy_from_shop via config.id).
106+
G.FUNCS.buy_from_shop({
107+
config = {
108+
id = "buy_and_use",
109+
ref_table = live_card,
110+
},
111+
})
112+
113+
-- Completion detection: union of the buy-phase and use-phase terminal
114+
-- conditions. Race-free: during use the state is PLAY_TAROT (predicate
115+
-- waits); the Ankh-noop never leaves SHOP and never acquires the lock.
116+
G.E_MANAGER:add_event(Event({
117+
trigger = "condition",
118+
blocking = false,
119+
func = function()
120+
local shop_count = (G.shop_jokers and G.shop_jokers.config and G.shop_jokers.config.card_count or 0)
121+
local shop_decreased = (shop_count == initial_shop_count - 1)
122+
local money_deducted = (G.GAME.dollars == initial_money - card.cost.buy)
123+
if shop_decreased and money_deducted and G.STATE == G.STATES.SHOP and not G.CONTROLLER.locks.use then
124+
sendDebugMessage("buy_and_use() → ok", "BB.ENDPOINTS")
125+
send_response(BB_GAMESTATE.get_gamestate())
126+
return true
127+
end
128+
return false
129+
end,
130+
}))
131+
end,
132+
}

src/lua/utils/openrpc.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,42 @@
172172
}
173173
]
174174
},
175+
{
176+
"name": "buy_and_use",
177+
"summary": "Buy and use a consumable from the shop in one step",
178+
"description": "Buy a shop consumable (Tarot/Planet/Spectral) and use it immediately, without occupying a consumable slot. Mirrors Balatro's \"Buy and Use\" shop button. Must provide a card index.",
179+
"tags": [
180+
{
181+
"$ref": "#/components/tags/shop"
182+
}
183+
],
184+
"params": [
185+
{
186+
"name": "card",
187+
"description": "0-based index of shop consumable to buy and use",
188+
"required": true,
189+
"schema": {
190+
"type": "integer",
191+
"minimum": 0
192+
}
193+
}
194+
],
195+
"result": {
196+
"name": "gamestate",
197+
"description": "Complete game state after the consumable is bought and used",
198+
"schema": {
199+
"$ref": "#/components/schemas/GameState"
200+
}
201+
},
202+
"errors": [
203+
{
204+
"$ref": "#/components/errors/BadRequest"
205+
},
206+
{
207+
"$ref": "#/components/errors/NotAllowed"
208+
}
209+
]
210+
},
175211
{
176212
"name": "cash_out",
177213
"summary": "Cash out and collect round rewards",

tests/cli/test_api_cmd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_api_all_methods_valid(self):
5858
from balatrobot.cli.api import Method
5959

6060
methods = [m.value for m in Method]
61-
assert len(methods) == 21
61+
assert len(methods) == 22
6262
assert "health" in methods
6363
assert "gamestate" in methods
6464

0 commit comments

Comments
 (0)