Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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)"
]
}
6 changes: 6 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 @@ -56,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()
Expand All @@ -73,6 +76,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
32 changes: 32 additions & 0 deletions lovely/seed.toml
Original file line number Diff line number Diff line change
@@ -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)"
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