Skip to content

feat!: balatrobot v2#155

Open
S1M0N38 wants to merge 139 commits into
mainfrom
dev
Open

feat!: balatrobot v2#155
S1M0N38 wants to merge 139 commits into
mainfrom
dev

Conversation

@S1M0N38 S1M0N38 changed the title BalatroBot v2 feat!: balatrobot v2 Feb 24, 2026
@S1M0N38 S1M0N38 marked this pull request as ready for review February 25, 2026 12:09
Copilot AI review requested due to automatic review settings February 25, 2026 12:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces version 2 of the balatrobot API with breaking changes (!). The main focus is on restructuring how tags are represented in the game state and improving error messages across all endpoints to be more actionable and helpful.

Changes:

  • Restructured tag representation from flat tag_name/tag_effect fields to nested tag objects with key, name, and effect fields
  • Added tags array to gamestate for tracking accumulated player-owned tags
  • Enhanced error messages across all endpoints with actionable guidance (e.g., suggesting reroll, sell, etc.)
  • Added support for selling jokers when Buffoon packs are open (SMODS_BOOSTER_OPENED state)
  • Implemented voucher effect extraction using game's localize function
  • Added comprehensive Tag enum definitions and test coverage

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/lua/utils/types.lua Updated Blind type to use nested Tag object instead of flat tag_name/tag_effect fields; added Tag class definition
src/lua/utils/openrpc.json Updated OpenRPC schema to reflect Tag object structure and enhanced sell endpoint description
src/lua/utils/gamestate.lua Implemented voucher effect extraction, tag ownership tracking, and updated blind tag structure
src/lua/utils/enums.lua Added comprehensive Tag.Key enum definitions for all Balatro tag types
src/lua/endpoints/sell.lua Added support for SMODS_BOOSTER_OPENED state with Buffoon pack validation
src/lua/endpoints/skip.lua Enhanced error message with actionable guidance
src/lua/endpoints/buy.lua Enhanced error messages with actionable guidance
src/lua/endpoints/add.lua Updated to support pack additions and refactored voucher handling to use dedicated SMODS function
src/lua/endpoints/use.lua Enhanced error messages with actionable guidance
src/lua/endpoints/play.lua Enhanced error message with actionable guidance
src/lua/endpoints/discard.lua Enhanced error messages with actionable guidance
src/lua/endpoints/pack.lua Enhanced error messages with actionable guidance
tests/lua/endpoints/test_skip.py Added tests for tag accumulation after skipping blinds
tests/lua/endpoints/test_pack.py Added tests for selling jokers during Buffoon pack selection
tests/lua/endpoints/test_gamestate.py Added comprehensive test coverage for voucher effects and tag structure
tests/lua/endpoints/test_buy.py Updated error message expectations
tests/lua/endpoints/test_add.py Updated error message expectations
docs/api.md Updated documentation to reflect new Tag structure and enhanced endpoint descriptions
Comments suppressed due to low confidence (4)

src/lua/endpoints/add.lua:409

  • The comment says "For jokers and consumables" but this else branch will also execute for vouchers and packs, creating unnecessary params that won't be used. Consider adding an explicit check: elseif card_type == "joker" or card_type == "consumable" then to match the comment and avoid creating unused params for vouchers and packs.
    else
      -- For jokers and consumables - just pass the key
      params = {
        key = args.key,
        skip_materialize = true,
        stickers = {},
        force_stickers = true,
      }

      -- Add edition if provided
      if edition_value then
        params.edition = edition_value
      end

      -- Add eternal if provided (jokers only - validation already done)
      if args.eternal then
        params.stickers[#params.stickers + 1] = "eternal"
      end

      -- Add perishable if provided (jokers only - validation already done)
      if args.perishable then
        params.stickers[#params.stickers + 1] = "perishable"
      end

      -- Add rental if provided (jokers only - validation already done)
      if args.rental then
        params.stickers[#params.stickers + 1] = "rental"
      end
    end

tests/lua/endpoints/test_skip.py:43

  • Grammar issue in comment: "because it used immediately" should be "because it is used immediately"
        assert "tag_investment" not in gamestate["tags"]  # because it used immediately

tests/lua/endpoints/test_skip.py:53

  • Grammar issue in comment: "because it used immediately" should be "because it is used immediately"
        assert "tag_investment" not in gamestate["tags"]  # because it used immediately

src/lua/utils/types.lua:58

  • Typo: "bilnd" should be "blind"
---@field status Blind.Status Status of the bilnd

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/lua/endpoints/test_skip.py Outdated
Comment on lines +52 to +53
assert gamestate["tags"][0]["key"] == "tag_polychrome"
assert "tag_investment" not in gamestate["tags"] # because it used immediately

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file has a bug that will cause test_skip_big_boss to fail. The test at line 54-58 (not shown in diff) expects the error message "Cannot skip Boss blind" but skip.lua line 39 now returns "Cannot skip Boss blind. Use select to select and play the boss blind." The expected error message in the test needs to be updated to match the new implementation.

Copilot uses AI. Check for mistakes.
Comment thread tests/lua/endpoints/test_skip.py Outdated
assert gamestate["blinds"]["big"]["status"] == "SKIPPED"
assert gamestate["blinds"]["boss"]["status"] == "SELECT"
assert gamestate["tags"][0]["key"] == "tag_polychrome"
assert "tag_investment" not in gamestate["tags"] # because it used immediately

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is checking if the string "tag_investment" is in a list of tag objects. Since gamestate["tags"] is a list of objects (each with "key", "name", "effect" fields), the in operator will never find a string match. This should likely be checking if any tag in the list has key == "tag_investment", such as: assert not any(tag["key"] == "tag_investment" for tag in gamestate["tags"])

Copilot uses AI. Check for mistakes.
Comment thread tests/lua/endpoints/test_skip.py Outdated
assert gamestate["state"] == "BLIND_SELECT"
assert gamestate["blinds"]["boss"]["status"] == "SELECT"
assert gamestate["tags"][0]["key"] == "tag_polychrome"
assert "tag_investment" not in gamestate["tags"] # because it used immediately

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is checking if the string "tag_investment" is in a list of tag objects. Since gamestate["tags"] is a list of objects (each with "key", "name", "effect" fields), the in operator will never find a string match. This should likely be checking if any tag in the list has key == "tag_investment", such as: assert not any(tag["key"] == "tag_investment" for tag in gamestate["tags"])

Copilot uses AI. Check for mistakes.
S1M0N38 added 22 commits June 11, 2026 19:23
Previously, used_vouchers extracted descriptions from static
voucher_data.description which was unreliable. Now uses
get_voucher_effect() that fetches effect text via the game's
localize() function with proper loc_vars for each voucher type.

Also adds strip_color_codes() helper and comprehensive parametrized
tests covering all 32 voucher types.

Closes #154.
Improve error messages across 6 endpoint files by adding actionable
guidance to help bots self-heal from failed tool calls.

Changes:
- buy.lua: Add endpoint suggestions for empty shop/slot errors
- use.lua: Add card parameter guidance for consumable errors
- discard.lua/play.lua: Add card limit suggestions
- pack.lua: Add pack buying and target selection hints
- skip.lua: Add boss blind selection suggestion
- Update test_buy.py to match new error messages

Closes #148.
- Remove .claude/ directory (settings.json, skills/balatrobot/SKILL.md)
- Remove CLAUDE.md in favor of AGENTS.md
- Remove .mux/ directory (init, mcp.jsonc, tool_env, tool_post)
- Remove .mdformat.toml (flags moved to Makefile)
- Add AGENTS.md with project structure and rules
- Add CONTEXT.md with glossary of domain terms
- Add .agents/skills/balatrobot/SKILL.md for pi skill
Replace verbose boilerplate with minimal, curated entries covering
macOS, Python, Lua, and project-specific ignores.
Inline --number and --exclude flags since the config file was removed.
Remove integration marker from pyproject.toml markers config.
The integration marker is no longer used. Remove auto-marking hooks
from conftest files and the @pytest.mark.integration decorator.
Rename BalatroInstance module to match its primary export.
Update import paths in tests.
Introduce BalatroPool with start/stop lifecycle, automatic port
allocation, fail-fast cleanup, and async context-manager support.
Includes InstanceInfo frozen dataclass for connection metadata.
StateFile wraps BalatroPool with a JSON state file (Jupyter pattern).
Atomic write on pool start, delete on stop. Supports PID-based liveness
checks, stale-file cleanup, and resolve-by-host:port or index.

Add platformdirs dependency for cross-platform state directory.
Replace single BalatroInstance with pool-based serve. Adds -n /
--num-instances flag for launching multiple instances. State file
is written on start and cleaned up on exit.
S1M0N38 and others added 3 commits June 12, 2026 16:14
When the deck has fewer cards than the hand limit, the buy endpoint
waits forever for a full hand that can never arrive. Use the actual
deck size (instead of the hardcoded hand limit) as the target, and
change the equality check to >= so partial hands are accepted.

Closes #198

Co-authored-by: DrLatBC <drlatbc@gmail.com>
Add a fixture that reproduces the SHOP state with a thin deck (2 cards,
< hand_limit) and an Arcana Pack available. The test buys pack[1]
and asserts the gamestate response is valid, confirming the endpoint
no longer hangs.

Co-authored-by: DrLatBC <drlatbc@gmail.com>
Replace the first-card ability.set heuristic with SMODS.OPENED_BOOSTER's
authoritative draw_hand flag in both buy.lua and pack.lua.

The old code inferred pack type from the first card's set (Tarot/Spectral),
but Black Hole (set=Spectral) can appear in Celestial packs via the soul
mechanism (0.3% chance), causing needs_hand=true and an indefinite hang
since Celestial packs never deal hand cards.

Closes #199

Co-authored-by: DrLatBC <drlatbc@gmail.com>
S1M0N38 added 26 commits June 15, 2026 08:46
Winning the ante-8 boss triggers win_game(), which pauses the game and
raises the win overlay AFTER play() had already returned won=true. The
bot was left in a paused session where every subsequent endless-mode
endpoint ran on wall-clock REAL time instead of turbo.

The polling event never recovered from the pause because event.lua
ignores `created_on_pause` in config (it only honours `pause_force`).
Switch to pause_force=true, and dismiss the overlay inside the event
once ROUND_EVAL is entered (G.round_eval already exists; the delayed
win events guard against a nil G.OVERLAY_MENU). The won path now flows
through the existing cash_out_button checkpoint before responding.
Drive the win cycle live instead of via save/load fixtures: `load`
resets the run and discards the paused/overlay state, which masked
the bug entirely. The play test asserts an endless play stays
responsive (elapsed < 5s) rather than crawling on wall-clock time;
the gamestate test asserts won=true persists across the
cash_out/next_round/select cycle. Removes the now-unused
state-SELECTING_HAND--won-true fixture that could not capture the
paused/overlay state.
5s is enough for the server health check on the turbo profile; the
previous 10s wait added needless delay to the quick-reference example.
Expose G.SETTINGS.paused as GameState.paused so callers can detect a
session stuck behind a blocking overlay (win screen, pause menu, game
over). Previously the only externally observable symptom of such a
stuck state was wall-clock speed — a signal no clean assertion could
rely on. Added to the gamestate extractor, type definition, OpenRPC
schema, and API docs.
The endless-mode regression test asserted elapsed < 5s, a flaky
wall-clock check that measures speed rather than game state. Rewrite
it to assert paused=false on the endless play via the new GameState
field. Verified red against the pre-fix play.lua (paused=true failure)
and green with the fix restored.
Adopt mike so each release keeps a frozen snapshot while main and dev
stay live. The version selector (extra.version.provider: mike) reads
versions.json that mike generates on the gh-pages branch.

Routing on push:
- main  -> /latest  (plus root redirect via set-default)
- v*    -> /<version> (frozen per-tag snapshot)
- dev   -> /dev

The old `mkdocs gh-deploy --force` wiped gh-pages on every push, which
is incompatible with keeping multiple versions side by side. Mike takes
over, committing one directory per version and leaving prior versions
untouched. fetch-depth: 0 lets mike read and extend gh-pages history,
and the mkdocs-material build cache is restored.

Verified locally by dry-running all three routes against a throwaway
branch: each produced its own version directory, versions.json listed
all entries, and the root index.html redirects to /latest.
Split overloaded "profile" term into "save profile" (Balatro's
numbered in-game save slots loaded from .jkr) and "runtime profile"
(named settings directories under src/lua/profiles/). Updated
"BalatroBot profile" to reference the new terminology and added
"Avoid" notes for deprecated terms. This clarifies the distinction
between Balatro's native save system and our mod's settings override
mechanism.
Remove unnecessary command substitution from git commit heredoc
example. Using -F - to read from stdin is simpler and more
direct than wrapping the heredoc in $(cat ...).
Remove parenthetical reference to balatrosettings mod from the
runtime profile definition's "Avoid" section. The term "settings
profile" is deprecated regardless of attribution.
The game permits using inventory consumables and selling jokers or
consumables while a booster pack is open (SMODS patches
can_use_consumeable and can_sell_card explicitly). The mod wrongly
restricted these actions:

- use endpoint rejected SMODS_BOOSTER_OPENED via requires_state and the
  card-selection state guard, and its completion check could hang because
  the game restores the booster state after the TAROT_INTERRUPT clears.
- sell endpoint hard-blocked every sale unless the open pack was a
  Buffoon pack, based on a false premise the game does not enforce.

Both endpoints now honour the game's actual behaviour. Use is a clean
non-destructive interrupt (the pack stays open, pack_choices unchanged).
Sell reuses the universal can_sell_card checks with no pack-type gate.

Closes #202.
Add opt-in screenshot logging: when enabled via --screenshots /
BALATROBOT_SCREENSHOTS=1, a PNG of the settled post-action game state
is written to <logs>/<port>/<id>.png after every successful API
response. Errors are excluded.

- New BB_SCREENSHOT module (utils/screenshot.lua) captures the frame
  via captureScreenshot -> encode("png") -> nativefs.write, reusing
  the proven pipeline from the standalone screenshot endpoint
- server.lua calls capture in the success branch of send_response
- settings.lua adds the flag and warn-disables it in headless mode
- In ondemand mode a second BB_RENDER flip re-arms rendering so the
  post-action frame (not a stale pre-action one) is captured; the
  dispatch-start flip stays untouched (see docs/adr/0002)
- Mirrors the existing --debug flag end-to-end: Config field, env var,
  and CLI option
- Write failures are logged and swallowed; they never affect the API
  response

Covered by config/CLI unit tests and a headfull+ondemand integration
test that asserts a valid PNG lands at the expected path.
The game permits using inventory consumables mid-eval. SMODS's
can_use_consumeable only excludes HAND_PLAYED/DRAW_TO_HAND/PLAY_TAROT,
and G.FUNCS.use_card explicitly restores the round_eval UI offset
afterwards, keeping the player in ROUND_EVAL.

The use endpoint rejected this via requires_state, and its completion
check could hang because the game restores ROUND_EVAL after the
consumable resolves. Both the requires_state list and the
state_restored whitelist now include ROUND_EVAL.

Removed the test that locked in the buggy rejection and added a
behaviour test that uses The Hermit mid-eval (consumable consumed,
state stays ROUND_EVAL).

Closes #203.
The game permits selling jokers and consumables mid-eval. SMODS's
can_sell_card has no ROUND_EVAL guard, and jokers/consumables share
area.config.type == 'joker'.

The sell endpoint rejected this via requires_state, and its completion
check could hang because the game stays in ROUND_EVAL after a sale.
Both the requires_state list and the valid_state whitelist now include
ROUND_EVAL.

Removed the test that locked in the buggy rejection and added a
behaviour test that sells a joker mid-eval (joker removed, money
increased, state stays ROUND_EVAL).

Closes #204.
The game permits using inventory consumables during blind selection.
SMODS's can_use_consumeable only excludes HAND_PLAYED/DRAW_TO_HAND/
PLAY_TAROT, so consumables like The Hermit work fine from BLIND_SELECT.

The use endpoint rejected this via requires_state, and its completion
check could hang because the game stays in BLIND_SELECT after the
consumable resolves. Both the requires_state list and the
state_restored whitelist now include BLIND_SELECT.

Removed the test that locked in the buggy rejection and added a
behaviour test that uses The Hermit mid-blind-select (consumable
consumed, state stays BLIND_SELECT).

Closes #205.
The game permits selling jokers and consumables during blind selection.
SMODS's can_sell_card has its BLIND_SELECT guard commented out, so
selling is allowed throughout blind selection.

The sell endpoint rejected this via requires_state, and its completion
check could hang because the game stays in BLIND_SELECT after a sale.
Both the requires_state list and the valid_state whitelist now include
BLIND_SELECT.

Removed the now-empty TestSellEndpointStateRequirements class (its
only remaining test locked in the buggy rejection) and added a
behaviour test that sells a joker mid-blind-select (joker removed,
money increased, state stays BLIND_SELECT).

Closes #206.
The game permits reordering jokers mid-eval and during blind selection.
CardArea:set_ranks sets states.drag.can based purely on area type;
G.jokers has type 'joker' with no state guard.

The rearrange endpoint rejected this via requires_state, and its
completion check for the jokers branch could hang because the game
stays in the original state. Both the requires_state list and the
jokers-branch state whitelist now include ROUND_EVAL and BLIND_SELECT.

The hand branch is unchanged (no hand is shown in these states).
Removed two tests that locked in the buggy jokers/consumables
rejection and updated the hand-from-wrong-state test to assert the
correct hand-branch error. Added behaviour tests that swap two
jokers in ROUND_EVAL and BLIND_SELECT.

Consumables rearrange in these states is a follow-up.

Partially addresses #207.
…_SELECT

The previous commit (582eea7) already extended the consumables-branch
state whitelist alongside jokers, but only added jokers behaviour
tests. This adds the matching consumables coverage.

Two new fixtures place c_fool + c_magician in inventory, landing in
ROUND_EVAL (after play) and BLIND_SELECT (after cash_out + next_round).
Two tests swap them and assert the new order.

Closes #207.
Capturing at send_response time photographed mid-animation frames (card
flights, score/dollar count-ups still in progress): the API response is
driven by game-state transitions, which settle logically before they
settle visually.

When screenshots are on, send_response now waits for the screen to
quiesce, then captures and responds in the same settled frame. Since the
server is single-client with one connection per request, holding N's
response blocks request N+1 at the accept gate — so each captured frame
is the genuine settled result of its action, even under rapid fire.
Errors stay immediate (no wait, no screenshot).

Quiescence predicate: every Moveable's position (x,y) and rotation (r)
at target, no active juice. Position/rotation convergence is finite;
DynaText (score/dollar counts) is a Moveable, so value tweens are
covered. Moveable.STATIONARY is deliberately NOT used: the hover-scale
term in move_scale (states.hover.is and 0.05) produces a perpetual
0.05 scale delta on the default-focused UI element even with no mouse,
so STATIONARY never becomes true (an earlier draft deadmanned at 15s on
every call). The static hover zoom is tolerated by checking x/y/r only.

ADR 0003 documents the decision, the STATIONARY pitfall, and the
no-correctness-timeout reasoning. A 15s deadman remains as insurance
against a future perpetual motion; empirically a 9-call run settles in
~1s/call average with zero deadman hits. ADR 0002's ondemand re-flip is
preserved at capture time.

server.lua extracts the response tail (encode/record/send/close) into a
finalize() closure so the wait can defer it; the success branch calls
capture_when_settled(id, finalize) when screenshots are on.
stylua expanded the long requires_state line and ruff collapsed the
short load_fixture calls. No behaviour change.
One-off manual-testing artifacts for the screenshot feature; not meant
to ship in the repo. The feature itself is covered by
tests/cli/test_screenshot.py.
When the game window is minimized, unfocused, or in another workspace,
love.mouse.getPosition() keeps returning the last known position, so the
cursor freezes over whatever element it last touched. The controller's
set_cursor_hover then keeps states.hover.is true on that element every
frame, and ui.lua paints it with darken(0.5) + G.C.UI.HOVER — a visible
tint baked into the screenshot.

The clear can't live in the quiescence event: E_MANAGER:update
(game.lua:2509) runs before CONTROLLER:update (game.lua:2638), so the
controller re-applies hover from the stale cursor on the same frame.

Instead, BB_SCREENSHOT.clear_hover_for_capture() runs from
BB_SERVER.update — the one slot after CONTROLLER:update but before
love.draw (which renders + captures). A suppress_hover flag is set when
capture is registered and cleared in the captureScreenshot callback.
While up, it iterates G.DRAW_HASH (every drawable node — cards and UI;
hover targets are Nodes in DRAW_HASH, not Moveables) and sets hover.is
false on each hovered node, persisting into the captured frame.

ADR 0003 documents the stale-cursor problem, the E_MANAGER/CONTROLLER
ordering constraint, and the DRAW_HASH-vs-MOVEABLES distinction.
Two screenshot-capture bugs found by running the full suite with
screenshots on (turbo, headfull):

1. Tag/blind hover popups persisted. clear_hover_for_capture only
   removed children.h_popup (card/joker descriptions). Tags, blinds,
   and vouchers use a separate children.alert popup (e.g. "Investment
   Tag") driven by per-frame callbacks (hover_tag_proxy) that keep it
   while collide.is is true; the stale cursor keeps collide.is true
   every frame, so the alert never dismissed. A source-level
   love.mouse.getPosition override was tried first but fails on frame
   ordering: hover_tag_proxy runs in Game:update's MOVEABLES loop
   (game.lua:2631) using last frame's collide.is, before the off-screen
   cursor takes effect in CONTROLLER:update (game.lua:2638) — so
   removal always lags one frame, after the capture. Instead clear
   directly from BB_SERVER.update (after the controller, before
   love.draw), now also removing children.alert and children.info.

2. GAME_OVER hung ~16s (deadman) and failed test_play_valid_cards_game_over.
   The Jimbo Card_Character on the game-over overlay is positioned
   off-screen with a permanent T!=VT offset that never converges, so
   the quiescence predicate (VT must reach T) never returned settled.
   is_settled now also treats a moveable as settled when its VT is
   unchanged since the previous poll (frozen), catching decorative
   off-screen elements whose VT is permanently offset but not moving.
   GAME_OVER settle time: 16.13s -> 1.25s, zero deadman hits.

Verified: tests/lua 520 passed, tests/cli 162 passed with
BALATROBOT_SCREENSHOTS=1. Also trims verbose comments in screenshot.lua.

ADR 0003 documents the three popup mechanisms, why the cursor override
fails, and the static boss_colour note (orange blind border is design).
The buy and pack endpoints used a plain count >= limit check, so they
rejected purchases of Negative-edition jokers/consumables when the
inventory was full. The game allows these because a Negative card
grants +1 slot via ability.card_limit.

Replicate the game's capacity formula from check_for_buy_space and the
SMODS-patched can_select_card:
  config.card_limit + ability.card_limit - ability.extra_slots_used

For buy, read card_limit/extra_slots from the live shop card
(G.shop_jokers.cards[pos]). For pack, the pack card is already the
live object.

Two seed-hunt fixtures reproduce full-inventory states with a
Negative joker deterministically: NEG003A + 9 rerolls yields a
Negative j_drunkard in the shop; NB001A + 4 buffoon mega packs yields
a Negative j_hologram inside the final pack. Non-Negative rejections
are unchanged.

Closes #208.
Reorganize per-instance logs into one self-contained directory and
disambiguate the two logging concepts that previously shared the
BALATROBOT_PATH_LOGS name.

New layout (previously flat with port-prefixed filenames at the
session level):

    logs/<timestamp>/<port>/
    ├── balatro.log          process stdout/stderr
    ├── requests.jsonl       API request trace
    ├── responses.jsonl      API response trace
    └── screenshots/<id>.png

The env var is split into two distinct names:

- BALATROBOT_LOGS (--logs, config.logs): user-facing parent dir,
  Python-only. No longer emitted to the subprocess, since the Lua
  mod never read it.
- BALATROBOT_LOG_DIR: per-instance dir, set imperatively by the
  launcher, read by the Lua mod. Replaces the previous pattern of
  emitting BALATROBOT_PATH_LOGS as input then overwriting it with a
  more specific value.

InstanceInfo.log_path still points at the log file (now
.../<port>/balatro.log), so `balatrobot list --json` is unchanged.

Closes #211
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
Add tests and fixtures for the buy_and_use endpoint, covering every
route the siblings (buy, use) exercise plus the faithfulness edge cases
specific to this endpoint:

- INVALID_STATE when not in SHOP
- BAD_REQUEST: missing card, empty shop, out-of-range index, unaffordable
- NOT_ALLOWED via the non-consumable guard (a Joker, which has no
  Buy-and-Use button) and via the real can_use_consumeable gate (The
  Magician, whose target-taking branch is SHOP-invisible)
- SUCCESS on a no-target Planet (used, not stored)
- SUCCESS when consumable slots are full — the #209 motivating case
- SUCCESS on Ankh-at-full-jokers: a faithful noop (money spent, no joker
  created) that proves we do not over-gate with a check_use pre-call
- Type validation for the card parameter

Tests are deterministic: completion is event-based (trigger='condition'
polling), so no flaky marks are used — consistent with test_use.py and
test_pack.py and unlike the inconsistent legacy marks in test_buy.py.

Fixtures use deterministic seeds. The Ankh edge case needs a Spectral in
the shop, which the Red Deck can never roll (spectral_rate defaults to
0), so it uses the Ghost Deck (spectral_rate=2) under seed ANKH0001 with
53 rerolls to land c_ankh in slot 1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants