Skip to content

Commit 7f77040

Browse files
committed
Merge branch 'sk/reroll-shop'
2 parents 3050aa8 + 608b2bb commit 7f77040

File tree

7 files changed

+140
-15
lines changed

7 files changed

+140
-15
lines changed

docs/logging-systems.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The system hooks into these game functions:
1616
- `skip_or_select_blind`: blind selection actions
1717
- `play_hand_or_discard`: card play actions
1818
- `cash_out`: end blind and collect rewards
19-
- `shop`: shop interactions
19+
- `shop`: shop interactions (`next_round`, `buy_card`, `reroll`)
2020
- `go_to_menu`: return to main menu
2121

2222
The JSONL files are automatically created when:

docs/protocol-api.md

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,16 @@ The BalatroBot API provides core functions that correspond to the main game acti
109109

110110
### Overview
111111

112-
| Name | Description |
113-
| ---------------------- | ------------------------------------------------------------------------------------------- |
114-
| `get_game_state` | Retrieves the current complete game state |
115-
| `go_to_menu` | Returns to the main menu from any game state |
116-
| `start_run` | Starts a new game run with specified configuration |
117-
| `skip_or_select_blind` | Handles blind selection - either select the current blind to play or skip it |
118-
| `play_hand_or_discard` | Plays selected cards or discards them |
119-
| `rearrange_hand` | Reorders the current hand according to the supplied index list |
120-
| `cash_out` | Proceeds from round completion to the shop phase |
121-
| `shop` | Performs shop actions: proceed to next round (`next_round`) or purchase a card (`buy_card`) |
112+
| Name | Description |
113+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
114+
| `get_game_state` | Retrieves the current complete game state |
115+
| `go_to_menu` | Returns to the main menu from any game state |
116+
| `start_run` | Starts a new game run with specified configuration |
117+
| `skip_or_select_blind` | Handles blind selection - either select the current blind to play or skip it |
118+
| `play_hand_or_discard` | Plays selected cards or discards them |
119+
| `rearrange_hand` | Reorders the current hand according to the supplied index list |
120+
| `cash_out` | Proceeds from round completion to the shop phase |
121+
| `shop` | Performs shop actions: proceed to next round (`next_round`), purchase a card (`buy_card`), or reroll shop (`reroll`) |
122122

123123
### Parameters
124124

@@ -130,7 +130,21 @@ The following table details the parameters required for each function. Note that
130130
| `skip_or_select_blind` | `action` (string): Either "select" or "skip" |
131131
| `play_hand_or_discard` | `action` (string): Either "play_hand" or "discard"<br>`cards` (array): Card indices (0-indexed, 1-5 cards) |
132132
| `rearrange_hand` | `cards` (array): Card indices (0-indexed, exactly `hand_size` elements) |
133-
| `shop` | `action` (string): Shop action ("next_round" or "buy_card")<br>`index` (number, required when `action` = "buy_card"): 0-based card index to purchase |
133+
| `shop` | `action` (string): Shop action ("next_round", "buy_card", or "reroll")<br>`index` (number, required when `action` = "buy_card"): 0-based card index to purchase |
134+
135+
### Shop Actions
136+
137+
The `shop` function supports multiple in-shop actions. Use the `action` field inside the `arguments` object to specify which of these to execute.
138+
139+
| Action | Description | Additional Parameters |
140+
| ------------ | ------------------------------------------------------------- | -------------------------------------------------------- |
141+
| `next_round` | Leave the shop and proceed to the next blind selection. ||
142+
| `buy_card` | Purchase the card at the supplied `index` in `shop_jokers`. | `index` _(number)_ – 0-based position of the card to buy |
143+
| `reroll` | Spend dollars to refresh the shop offer (cost shown in-game). ||
144+
145+
!!! note "Future actions"
146+
147+
Additional shop actions `buy_and_use_card`, `open_pack`, and `redeem_voucher` will be added soon.
134148

135149
### Errors
136150

src/lua/api.lua

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,13 +695,50 @@ API.functions["shop"] = function(args)
695695
API.send_response(game_state)
696696
end,
697697
}
698+
elseif action == "reroll" then
699+
-- Capture the state before rerolling for response validation
700+
local dollars_before = G.GAME.dollars
701+
local reroll_cost = G.GAME.current_round and G.GAME.current_round.reroll_cost or 0
702+
local expected_dollars = dollars_before - reroll_cost
703+
704+
local times_rerolled_before = 0
705+
if G.GAME.round_scores and G.GAME.round_scores.times_rerolled then
706+
times_rerolled_before = G.GAME.round_scores.times_rerolled.amt or 0
707+
end
708+
709+
if dollars_before < reroll_cost then
710+
API.send_error_response(
711+
"Not enough dollars to reroll",
712+
ERROR_CODES.INVALID_ACTION,
713+
{ dollars = dollars_before, reroll_cost = reroll_cost }
714+
)
715+
return
716+
end
717+
718+
-- no UI element required for reroll
719+
G.FUNCS.reroll_shop(nil)
720+
721+
---@type PendingRequest
722+
API.pending_requests["shop"] = {
723+
condition = function()
724+
return utils.COMPLETION_CONDITIONS.shop_idle()
725+
and G.GAME.round_scores
726+
and G.GAME.round_scores.times_rerolled
727+
and G.GAME.round_scores.times_rerolled.amt == times_rerolled_before + 1
728+
and G.GAME.dollars == expected_dollars
729+
end,
730+
action = function()
731+
local game_state = utils.get_game_state()
732+
API.send_response(game_state)
733+
end,
734+
}
698735

699-
-- TODO: add other shop actions [buy_and_use | reroll | open_pack | redeem_voucher]
736+
-- TODO: add other shop actions [buy_and_use | open_pack | redeem_voucher]
700737
else
701738
API.send_error_response(
702739
"Invalid action for shop",
703740
ERROR_CODES.INVALID_ACTION,
704-
{ action = action, valid_actions = { "next_round" } }
741+
{ action = action, valid_actions = { "next_round", "buy_card", "reroll" } }
705742
)
706743
return
707744
end

src/lua/log.lua

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,21 @@ function hook_buy_card()
237237
sendDebugMessage("Hooked into G.FUNCS.buy_card for logging", "LOG")
238238
end
239239

240+
-- -----------------------------------------------------------------------------
241+
-- reroll_shop Hook
242+
-- -----------------------------------------------------------------------------
243+
244+
---Hooks into G.FUNCS.reroll_shop
245+
function hook_reroll_shop()
246+
local original_function = G.FUNCS.reroll_shop
247+
G.FUNCS.reroll_shop = function(e)
248+
local function_call = { name = "shop", arguments = { action = "reroll" } }
249+
LOG.schedule_write(function_call)
250+
return original_function(e)
251+
end
252+
sendDebugMessage("Hooked into G.FUNCS.reroll_shop for logging", "LOG")
253+
end
254+
240255
-- -----------------------------------------------------------------------------
241256
-- hand_rearrange Hook
242257
-- -----------------------------------------------------------------------------
@@ -369,6 +384,8 @@ function LOG.init()
369384
hook_discard_cards_from_highlighted()
370385
hook_cash_out()
371386
hook_toggle_shop()
387+
hook_buy_card()
388+
hook_reroll_shop()
372389
hook_hand_rearrange()
373390

374391
sendInfoMessage("Logger initialized", "LOG")

src/lua/types.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
---@field cards number[] Array of card indices for every card in hand (0-based)
5454

5555
---@class ShopActionArgs
56-
---@field action "next_round" | "buy_card" The action to perform
56+
---@field action "next_round" | "buy_card" | "reroll" The action to perform
5757
---@field index? number The index of the card to act on (buy, buy_and_use, redeem, open) (0-based)
5858

5959
-- TODO: add the other actions "reroll" | "buy" | "buy_and_use" | "redeem" | "open"
@@ -139,6 +139,8 @@
139139
---@field discards_used number Number of discards used
140140
---@field hands_left number Number of hands remaining
141141
---@field hands_played number Number of hands played
142+
---@field reroll_cost number Current dollar cost to reroll the shop offer
143+
---@field free_rerolls number Free rerolls remaining this round
142144
---@field voucher table Vouchers for this round
143145

144146
-- Selected deck info (G.GAME.selected_back)

src/lua/utils.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ function utils.get_game_state()
110110
-- "free_rerolls": int, (default 0) -- Number of free rerolls in the shop?
111111
hands_left = G.GAME.current_round.hands_left, -- Number of hands left for this round
112112
hands_played = G.GAME.current_round.hands_played, -- Number of hands played in this round
113+
114+
-- Reroll information (used in shop state)
115+
reroll_cost = G.GAME.current_round.reroll_cost, -- Current cost for a shop reroll
116+
free_rerolls = G.GAME.current_round.free_rerolls, -- Free rerolls remaining this round
113117
-- "idol_card": { -- what's a idol card?? maybe some random used by some joker/effect? idk
114118
-- "rank": "Ace" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "Jack" | "Queen" | "King",
115119
-- "suit": "Spades" | "Hearts" | "Diamonds" | "Clubs",

tests/lua/endpoints/test_shop.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,57 @@ def test_shop_buy_card(self, tcp_client: socket.socket) -> None:
244244
assert purchase_response["game"]["dollars"] == 4
245245
assert purchase_response["jokers"]["cards"][0]["cost"] == 6
246246

247+
# ------------------------------------------------------------------
248+
# reroll shop
249+
# ------------------------------------------------------------------
250+
251+
def test_shop_reroll_success(self, tcp_client: socket.socket) -> None:
252+
"""Successful reroll keeps us in shop and updates cards / dollars."""
253+
254+
# Capture shop state before reroll
255+
before_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
256+
assert before_state["state"] == State.SHOP.value
257+
before_keys = [
258+
c["config"]["center_key"] for c in before_state["shop_jokers"]["cards"]
259+
]
260+
dollars_before = before_state["game"]["dollars"]
261+
reroll_cost = before_state["game"]["current_round"]["reroll_cost"]
262+
263+
# Perform the reroll
264+
after_state = send_and_receive_api_message(
265+
tcp_client, "shop", {"action": "reroll"}
266+
)
267+
268+
# verify state
269+
assert after_state["state"] == State.SHOP.value
270+
assert after_state["game"]["dollars"] == dollars_before - reroll_cost
271+
after_keys = [
272+
c["config"]["center_key"] for c in after_state["shop_jokers"]["cards"]
273+
]
274+
assert before_keys != after_keys
275+
276+
def test_shop_reroll_insufficient_dollars(self, tcp_client: socket.socket) -> None:
277+
"""Repeated rerolls eventually raise INVALID_ACTION when too expensive."""
278+
279+
# Perform rerolls until an error is returned or a reasonable max tries reached
280+
max_attempts = 10
281+
for _ in range(max_attempts):
282+
response = send_and_receive_api_message(
283+
tcp_client, "shop", {"action": "reroll"}
284+
)
285+
286+
# Break when error encountered and validate
287+
if "error" in response:
288+
assert_error_response(
289+
response,
290+
"Not enough dollars to reroll",
291+
["dollars", "reroll_cost"],
292+
ErrorCode.INVALID_ACTION.value,
293+
)
294+
break
295+
else:
296+
pytest.fail("Rerolls did not exhaust dollars within expected attempts")
297+
247298
# ------------------------------------------------------------------
248299
# buy_card validation / error scenarios
249300
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)