Skip to content

feat!: balatrobot v2#155

Open
S1M0N38 wants to merge 145 commits into
mainfrom
dev
Open

feat!: balatrobot v2#155
S1M0N38 wants to merge 145 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 added 30 commits June 15, 2026 08:46
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.
Add the "Reroll Boss Blind" entry to the glossary so the term used
across the upcoming endpoint has a single canonical definition. It
names the voucher-gated player action available during BLIND_SELECT
that replaces the upcoming boss blind with a random weighted pick for
$10, requiring the Director's Cut (once per ante) or Retcon
(unlimited) voucher.

The entry also flags what to avoid: "boss reroll" (ambiguous with the
Boss Tag's free reroll and the `set` debug override) and "prescribe
boss". Pinning the term now prevents the same concept from fragmenting
into several names in the API, tests, and docs.
Add a dedicated `reroll_boss` endpoint that replaces the upcoming boss
blind with a random weighted pick for $10, mirroring Balatro's "Reroll
Boss" button. Requires the Director's Cut (one reroll per ante) or
Retcon (unlimited) voucher. This is semantically distinct from both the
free Boss-Tag reroll and the deterministic `set` boss override — both of
which set G.from_boss_tag to skip the $10 charge, which we deliberately
do not.

The endpoint faithfully replicates the game's behaviour rather than
re-deriving it:

- The pre-flight gate is a decomposed union that equals the game's
  visibility boolean exactly (G.FUNCS.reroll_boss_button): missing
  voucher -> NOT_ALLOWED, Director's Cut per-ante limit -> NOT_ALLOWED,
  affordability (dollars - bankrupt_at) - 10 < 0 -> NOT_ALLOWED. Error
  phrasing mirrors reroll.lua.
- G.FUNCS.reroll_boss({}) is invoked directly with an ephemeral empty
  arg, so the player pays $10 and the new boss stays random. We never
  set G.from_boss_tag or seed perscribed_bosses.
- Completion is predicate alpha: wait on
  G.CONTROLLER.locks.boss_reroll == nil, then respond with the
  gamestate. We do NOT gate on boss-key-change — get_new_boss uses a
  min-bosses_used filter that legally redraws the same key once the
  pool saturates under Retcon, so a key-change gate would hang there
  and would respond ~0.3s too early on the normal path.
- A new gamestate field blinds.boss.reroll_available reports the exact
  game gate, so clients can predict whether the action will succeed
  without trial-and-error.

Registered in balatrobot.lua (blind-selection group, beside skip/select),
added to the OpenRPC spec, exposed through the CLI Method enum (and its
count assertion), typed in types.lua, and documented in api.md.

Closes #212
Add six integration tests mirroring the structure of test_reroll.py:
happy path (Director's Cut), state requirement, no voucher, can't
afford, Director's Cut per-ante limit, and Retcon unlimited. No flaky
marks — completion is event-based via predicate alpha, and the primary
success signal is the $10 deduction (boss-key-change is a secondary
ante-1-specific check with a comment explaining why it is safe there).

Add five fixtures under "reroll_boss". The voucher-holding fixtures
(F2/F3/F4) redeem the natural shop voucher at index 0 before adding the
target voucher, so the voucher area never holds two cards at once — the
target lands cleanly at index 0 once the area is empty.

Closes #212
Add the "Sort Hand" entry to the glossary so the term used across the
upcoming endpoint has a single canonical definition. It names the
in-game play-bar action available during SELECTING_HAND that reorders
the hand by rank (sort by rank) or by suit (sort by suit).

The entry also flags what to avoid: "sort hand by value" (the game's
internal name, misleading), "arrange" (ambiguous with rearrange), and
"reorder". Pinning the term now prevents the same concept from
fragmenting into several names in the API, tests, and docs.
Add a dedicated `sort` endpoint that reorders the cards in hand by rank
or by suit, mirroring Balatro's in-game "Sort by Rank" / "Sort by Suit"
play-bar buttons. The game computes the new order for you — unlike
`rearrange`, where the caller supplies the order. Only available during
SELECTING_HAND.

The endpoint faithfully replicates the game's behaviour rather than
re-deriving it:

- The `by` parameter is a manual enum check ("rank"|"suit") because the
  shared validator has no enum support. Any other value is BAD_REQUEST,
  matching the phrasing of sibling endpoints.
- The in-game comparator is invoked directly: G.FUNCS.sort_hand_value
  for rank (A>K>...>2, then Spades>Hearts>Clubs>Diamonds) and
  G.FUNCS.sort_hand_suit for suit (Spades>Hearts>Clubs>Diamonds, then
  rank desc). We never hand-roll an ordering.
- Completion is predicate alpha: wait on STATE==SELECTING_HAND with
  G.hand populated, then respond with the gamestate.

Registered in balatrobot.lua (hand-reorder group, beside rearrange),
added to the OpenRPC spec, exposed through the CLI Method enum (and its
count assertion), typed in types.lua, and documented in api.md.

Closes #213.
Add six tests for the sort endpoint, covering every route:

- SUCCESS on sort-by-rank: the hand is a permutation of the input (no
  cards lost or created) and strictly descending by (rank, suit).
- SUCCESS on sort-by-suit: same permutation contract, descending by
  (suit, rank).
- INVALID_STATE when not in SELECTING_HAND (SHOP fixture).
- BAD_REQUEST on missing 'by' (validator rejects).
- BAD_REQUEST on non-string 'by' (validator type check).
- BAD_REQUEST on 'by' not in {"rank","suit"} (manual enum gate).

Tests are deterministic: completion is event-based (trigger='condition'
polling), so no flaky marks are used.

Two fixtures under "sort": an 8-card SELECTING_HAND hand (pre-scrambled
via rearrange so the sort actually moves cards), and a SHOP state
reached by setting chips, playing a single card, and cashing out.

Closes #213.
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