Conversation
There was a problem hiding this comment.
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_effectfields to nestedtagobjects withkey,name, andeffectfields - Added
tagsarray 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" thento 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.
| assert gamestate["tags"][0]["key"] == "tag_polychrome" | ||
| assert "tag_investment" not in gamestate["tags"] # because it used immediately |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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"])
| 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 |
There was a problem hiding this comment.
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"])
0f4937c to
932e5cd
Compare
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.
Closes #143.
- 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.
Add the "fair-play bot" entry to the glossary so the consumer-side discipline the API is built to support has a single canonical name. A fair-play bot deliberately ignores the true `value` of `hidden` cards and models only what a human observer could know — deducing from sort order (`sort`) and, with the upcoming `revealed` field, from the signal that a hidden card's identity was momentarily exposed. The term names the audience the `revealed` field serves: the API is omniscient (see that entry), so exposing `revealed` only matters to a consumer that has chosen not to use that omniscience. It flags what to avoid: "honest bot" and "legitimate bot", which imply the omniscient path is dishonest. Pinning the term now keeps the concept consistent as observer-knowledge signals are added to the API.
Under a flip blind (e.g. The House) the whole hand is dealt face-down. Using a conversion consumable (Magician, Death, Sigil, ...) on a hidden card triggers the game's flip->modify->flip animation: the card is briefly shown face-up to a human, then flipped back, ending `hidden` despite its identity having been exposed. A fair-play bot that refuses to read hidden-card data has no signal that this happened. Add an optional, transient `revealed` boolean to Card.State, emitted only by the use endpoint. It co-occurs with `hidden: true` and tells a fair-play consumer "you may now know this card." gamestate.lua owns a module-level revealed registry consulted by extract_card_state; use.lua snapshots the hidden cards a conversion consumable will flip (mirroring the use_consumeable branches on G.hand.highlighted, and the whole G.hand.cards for Sigil/Ouija) before G.FUNCS.use_card clears highlighting, then stamps and clears the registry around the response extraction so `revealed` never leaks into gamestate, play, sort or buy_and_use. Deliberately not based on ability.wheel_flipped: that is dealing-time data, blind-coupled, and cleared as a side effect by Card:flip()'s back->front branch. Closes #215
Add fixtures and tests for the transient `revealed` field. A deterministic flip-blind fixture (The House keeps the whole hand face-down while no hands or discards have been played) provisions a conversion consumable, so the reveal scenario is reproducible without the probabilistic The Wheel. A second fixture provisions Sigil to exercise the whole-hand conversion branch. Tests guard: targeted cards are revealed+hidden while an untargeted hidden card is not; the whole-hand Sigil path reveals every card; `revealed` is transient (absent from a plain gamestate after use); a normal face-up use never stamps revealed; and buy_and_use never emits it. A shared helper scans every card area and tolerates the Lua->JSON empty-table->[] quirk for flag-less cards. Closes #215
The `hands` field of GameState (Hand type, extract_hand_info) had no test coverage. Add a TestGamestateHands class asserting all seven sub-fields across the reachable states: - present_at_run_start: all 12 hands are emitted at run start - default_values: order/level/mult/chips/played/played_this_round/ example for every hand, sourced from G.GAME.hands init (vendors/balatro/game.lua:2002-2013) - level_up_via_planet: add+use Planet cards raises level/mult/chips per the level_up_hand formula (common_events.lua:464) - played_counter: playing a High Card increments played counters Reuses the existing state-SELECTING_HAND fixture; no new fixture needed. First task of #217.
The `Card.Modifier` sub-fields (seal, edition, enhancement, eternal,
perishable, rental) were `# TODO` stubs. test_add.py already exercised
the add→modifier→response path, but only ever asserted the *named*
modifier key. Fill the stubs with authoritative gamestate-extraction
checks that assert the modifier object is EXACTLY the expected dict
(no key leakage), per family:
- seal ×4 (Red/Blue/Gold/Purple) on a playing card
- edition ×4 on playing card and joker; e_negative on consumable
- enhancement ×8 on a playing card
- eternal, perishable (1/5/10), rental on jokers
- co-occurrence: seal + edition + enhancement on one card
Reach modifiers via the `add` endpoint, reusing the existing `add`
fixtures (no new fixtures).
Investigation finding (documented, not fixed): a card with no modifiers
serializes as `[]` not `{}` — an rxi/json.lua quirk where empty Lua
tables become JSON arrays. Already pinned by
test_modifier_absent_fields; out of scope for this coverage task.
Part of #217.
The `Card.State.debuff` sub-field was a `# TODO` stub. Reach it by
injecting a debuff boss via `set`, skipping Small+Big, then selecting the
Boss — landing in SELECTING_HAND with the boss's debuff rules applied to
the dealt hand (Blind:debuff_card → card:set_debuff).
- parametrize over the four suit-debuff bosses (bl_club/bl_goad/bl_head/
bl_window): matching-suit cards get exactly {debuff: true}; all others
stay un-debuffed
- bl_plant: face cards (J/Q/K) get exactly {debuff: true}
The extractor is a pass-through (if card.debuff then state.debuff = true);
bl_plant guarantees a positive hit. The suit positive case is conditional
on the seed-dependent dealt hand containing that suit (an 8-card hand may
miss any one suit); negatives always run.
Add helpers _reach_boss_selecting_hand and _is_card_debuffed (robust to
the empty-state `[]` serialization quirk) for reuse by the sibling hidden
and highlight state tests.
Part of #217.
The `Card.State.hidden` sub-field was a `# TODO` stub. The extractor sets
state.hidden when card.facing == "back" (face-down), which boss blinds
trigger via Blind:stay_flipped. Reach with the same boss-injection helper:
- bl_house: hides the entire first hand (deterministic) — every card has
exactly {hidden: true}
- bl_mark: hides face cards (J/Q/K) only — faces get exactly {hidden:
true}, numbered cards stay visible
The probabilistic/conditional bosses (bl_wheel, bl_fish) are intentionally
not asserted. Add a _is_card_hidden helper (robust to the empty-state `[]`
serialization quirk) alongside _is_card_debuffed.
Part of #217.
blinds.boss.reroll_available is extracted by get_blinds_info behind a gate (affordable: dollars-bankrupt_at >= 10, AND (v_retcon OR (v_directors_cut AND not boss_rerolled))). The field's full endpoint-behavior matrix already lives in test_reroll_boss.py; add a lean parametrized extractor-contract test in test_gamestate.py (the field's proper home) pinning the four static branches via the existing reroll_boss-category fixtures: - Director's Cut + affordable ($20) → True - Director's Cut + unaffordable ($5) → False (affordability gate) - no voucher → False - Retcon + affordable ($30) → True The post-reroll (boss_rerolled → False) and Retcon-stays-True cases remain owned by test_reroll_boss.py; a docstring cross-references it. Part of #217.
The top-level `challenge` field (conditionally present) had no coverage in test_gamestate.py. The extractor guards with `if G.GAME.challenge then`, so the challenge id is present only during a Challenge Run and omitted (not null) otherwise. Add two tests in TestGamestateTopLevel: - absent_in_normal_run: a non-challenge run has no challenge key at all (the conditional-absence contract — the gap not covered elsewhere) - present_during_challenge_run: starting c_omelette_1 yields the bare id test_start.py already asserts the field's value across the broader challenge-run start matrix (deck/stake/seed/effect/conflict); a docstring cross-references it. Part of #217.
The `elseif ability_set == "Edition"` branch in extract_card() and the matching `"EDITION"` value in the Card.Set enum were unreachable: editions are stored on `card.edition` and surfaced in `modifier.edition`, orthogonal to a card's type, so `ability.set` is never "Edition". Verified empirically by adding edition cards of every type and edition, then scanning the full gamestate: zero cards report set=="EDITION". The openrpc.json CardSet spec already omitted the value. Closes #218.
…e fixtures Replicate G.FUNCS.unlock_all (functions/button_callbacks.lua) inside the existing name=='BalatroBot' gate: force discovered/unlocked/alerted=true across G.P_CENTERS/G.P_BLINDS/G.P_TAGS. all_unlocked alone bypasses stake/challenge locks but never sets G.P_CENTERS[*].discovered, so hosts with different profile histories (dev Mac vs fresh container) diverge on tag/shop pools and card effect-text rendering, breaking fixture portability. Force immediately in setup() (init_item_prototypes runs before mod load) and re-apply via an init_item_prototypes wrapper for profile reloads. Double- gated on profile name so a non-BalatroBot profile is never mutated. Refs #220
Add an optional stream_port alongside the rpc port so an HLS stream port can follow the same instance lifecycle. The field defaults to None at every layer (Config, InstanceInfo, state file), so nothing changes unless a port is provided — keeping the local-process platforms untouched. - Config.stream_port: Python-only override (intentionally absent from ENV_MAP, so it is never serialized to BALATROBOT_* env on the host). - InstanceInfo.stream_port + stream_url property (None when unset). - StateFile (de)serializes stream_port; missing key reads back as None, so legacy state files keep resolving. - BalatroPool allocates one stream port per instance when BALATROBOX_STREAM=1 and passes it through to each BalatroInstance. - `balatrobot serve` and `balatrobot list` print the stream URL only when a stream port is recorded.
On Docker Desktop the published-port proxy accepts the host TCP connection before the container's listener is ready, then resets it — surfacing as httpx.ReadError rather than ConnectError. The retry loop only caught ConnectError and TimeoutException, so a cold container could fail the health check outright on the first reset instead of retrying. Catch ReadError too, mirroring the existing connect/timeout tolerance.
Introduce the `docker` platform: instead of spawning Balatro on the host and injecting the mod via Lovely, balatrobot drives a pre-baked `balatrobox:latest` container (LOVE + Lovely + Steamodded + the balatrobot mod). One container per instance; balatrobot never touches game files on the host. DockerLauncher.build_cmd assembles a foreground `docker run --rm -i` argv so the existing Popen lifecycle (poll/terminate) works unchanged: - maps the rpc port (host:container) and, when Config.stream_port is set, maps it to the container's internal :8080 for HLS; - forces BALATROBOT_HOST=0.0.0.0 inside the container and forwards the BALATROBOT_* driving vars from config as -e flags (no blanket host-env forwarding); - read-only bind-mounts local checkouts via BALATROSRC/BALATROBOT/ DEBUGPLUS_LOCAL_REPO for dev without rebuilds, and identity-mounts BALATROBOT_DOCKER_MOUNTS paths read-write (used by the test suite to expose fixtures/temp dirs, since load/save open the exact path string); - forwards BALATROBOX_* and *_GITHUB_* fetch vars only when set. BALATROBOX_PLATFORM is intentionally ignored (build/run-arch concern). validate_paths fails fast when the docker CLI or the image is missing (the latter with a `docker build` hint). Register `docker` in the platform dispatcher and the serve --platform choices. Wire the lua test suite to set BALATROBOT_DOCKER_MOUNTS to the repo root when BALATROBOT_PLATFORM=docker, so the container can see tests/fixtures. Document the platform in docs/cli.md and add glossary terms to CONTEXT.md.
Pin --basetmp=.pytest_tmp so pytest writes its per-session temp trees into the repo (gitignored) instead of the system tmp dir. Keeps test artifacts alongside the project, easier to inspect and clean up, and stops them from littering $TMPDIR across runs.
create_temp_save_path() has no callers anywhere in the suite, so its tempfile/uuid imports are dead weight. Remove the helper and the two imports it was the sole user of.
Rejoin the split `and` chain for G.PROFILES[G.SETTINGS.profile] back to one line. Pure formatting; no behavioral change to the BalatroBot profile gate in init_item_prototypes.
Under docker, BALATROBOT_LOG_DIR (screenshot logging, JSONL recording) must both exist inside the container and be known to the Lua mod for writes to reach the host. BaseLauncher now exposes instance_dir on start(), and DockerLauncher identity-mounts it read-write and forwards BALATROBOT_LOG_DIR from it when set. Local platforms ignore the attribute and keep using the env dict.
Drop the runtime python multiprocessing.cpu_count() probe (and the MAX_XDIST knob) in favour of explicit per-platform defaults: CLI 2, LUA 6 on darwin / 4 under docker / 2 otherwise. Removes a python dependency from the Makefile and makes worker counts predictable instead of hardware-dependent.
Expose G.GAME.last_tarot_planet — the key of the last Tarot or Planet used this run — so consumers can reason about The Fool, which creates exactly that card. The field is nil in init_game_object until first Tarot/Planet use (set in misc_functions.lua), so it is conditionally absent rather than null, mirroring challenge/seed. Documented in OpenRPC, types.lua, and api.md. Co-authored-by: Brent Garber <me@bgarber.work>
Pin the last_tarot_planet extractor contract across four behaviours: the key is absent on a fresh run, appears after using a Tarot (c_hermit) or a Planet (c_pluto), and The Fool creates exactly the card it names. Hermit and Pluto need no card target, so they are used bare inline — no new fixture is required. Co-authored-by: Brent Garber <me@bgarber.work>
Add a Hand.Name alias for the 12 vanilla poker-hand names and reuse it two ways: tighten the GameState.hands key type (string -> Hand.Name) and add round.most_played_hand, the poker hand played most this run and the per-round target a bot must avoid under The Ox. The value defaults to 'High Card' and is only recomputed when a Boss blind is defeated. Royal Flush is a display alias of Straight Flush, never a key in G.GAME.hands, so it is excluded from the enum. Documented in OpenRPC, types.lua, enums.lua, and api.md.
Pin the Hand.Name contract across four behaviours: round.most_played_hand is present and a valid Hand.Name at SELECTING_HAND, defaults to 'High Card' before any Boss blind is defeated, recomputes to the most-played hand after a Boss defeat (driven by playing a Pair), and every hands key is a Hand.Name member (Royal Flush never appears).
last_tarot_planet is set only when a Tarot or Planet is used (guarded by the `center.set == 'Tarot' or 'Planet'` check in misc_functions.lua), yet it was typed as a bare `string` in both the lua annotation and the openrpc.json schema. Any string admitted typos and even Spectral keys that can never occur. Tighten to a union of the existing closed enums: - types.lua: `string?` -> `(Card.Key.Consumable.Tarot | Card.Key.Consumable.Planet)?` - openrpc.json: `"type": "string"` -> `anyOf[TarotKey, PlanetKey]` The single consumer is The Fool, which copies this key. Note that 'c_fool' is a valid attainable value (using The Fool self-sets it); can_use then gates it, so it stays within the union. Zero runtime/data change; this is type-honesty only.
Update dev dependencies to their latest releases: - ruff 0.15.14 -> 0.16.0 - ty 0.0.40 -> 0.0.64 Bumps both the lower-bound constraints in pyproject.toml and the locked versions in uv.lock.
Exclude .pi-subagents/artifacts, generated by the pi-subagents tooling, so it does not pollute the working tree or diffs.
Ruff 0.16 expanded its default rule set (59 -> 413 enabled rules), surfacing many new violations. Apply safe auto-fixes and resolve the rest so `make quality` passes clean on the upgraded tools. - Modernize via UP rules: TimeoutError aliases, datetime.UTC, collections.abc generics, dropped redundant default type args - Annotate async context managers with Self (PYI034) - Pass explicit check=False on fire-and-forget subprocess calls - Rewrite set(gen) as comprehensions, drop mutable default arg, use sys.exit, remove redundant global/return/shebang - Suppress intentional patterns with noqa: broad CLI catches (BLE001), local-time session timestamps (DTZ005), and one-shot process spawns (ASYNC220/230) - Fix ty diagnostics: use a real Path instead of mocking __str__, and drop now-unused ty:ignore directives
mdformat (run via `make format`) reflows the requests.post call and adjusts blank-line spacing in the example snippet.
Expose G.GAME.starting_deck_size as a top-level integer on the gamestate payload. The field is the deck-size baseline set at run start (52 for a standard deck), which the Erosion joker's mult formula references. Always present during a run (initialized by init_game_object to 52 and reaffirmed to #G.playing_cards at run start), so it is assigned directly alongside the other always-present scalars (won, money) rather than guarded as conditionally absent. Documented in types.lua and openrpc.json alongside the extraction. Co-authored-by: Brent Garber <me@bgarber.work>
Expose the per-round targets that The Idol, Mail-In Rebate, Ancient Joker, Castle, and To Do List pick, so bots can plan plays without parsing the localized effect text. The four card targets use inline table types with their exact shape (idol: suit+rank; mail: rank; ancient/castle: suit). To Do List is a Hand.Name[] list because each owned copy carries its own target. Co-authored-by: Brent Garber <me@bgarber.work>
and many more... (see the ones with
completed-in-devlabel)This new version will introduce breaking changes. Many part have been refactored while other are still WIP. When everything have been stabilized, we still have to