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.
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.
The reroll_boss feature (f58ce86) added a `reroll_available` boolean to the boss blind in the gamestate output, documented in types.lua, openrpc.json, and api.md. This pre-existing structure test was never updated to expect the new field, so it failed on fresh-run fixtures (no vouchers → reroll_available is False).
Add the "Challenge Run" entry to the glossary so the term used across the upcoming start-endpoint extension has a single canonical definition. It names a run variant started from one of Balatro's 20 fixed presets (e.g. The Omelette, Mad World, Jokerless), each dictating a specific deck, starting jokers/consumeables/vouchers, rules/modifiers, and card restrictions. The entry also distinguishes the run mode from its identifier: a run is a "Challenge Run", while the preset itself is named by a challenge id with the c_ prefix (e.g. c_omelette_1) — the value the upcoming `challenge` param will accept. It flags what to avoid: "challenge" as a bare noun (ambiguous with the id), "challenge mode" (the in-game Challenges tab UI), and "custom run". Pinning the term now prevents the same concept from fragmenting across the API, enums, tests, and docs.
Extend the existing `start` endpoint to support Challenge Runs — Balatro's
20 fixed-preset runs (e.g. The Omelette, Mad World, Jokerless). Add an
optional `challenge` param that is mutually exclusive with `deck`/`stake`
but composes freely with `seed`.
`deck` and `stake` drop from schema-required to optional (the shared
validator rejects missing-required fields before execute runs, so they
cannot stay required now that `challenge` is a valid alternative). All
combination logic moves into execute:
- Exclusivity checked first (independent of value validity): challenge
with deck or stake → BAD_REQUEST "cannot be combined".
- Challenge branch resolves the id by scanning the live G.CHALLENGES
table (mirrors SMODS.Challenge.get_obj) — runtime validation, so
SMODS-injected challenges are reachable. Unknown id → BAD_REQUEST.
- Challenge execution mirrors G.FUNCS.start_challenge_run
(button_callbacks.lua:1847) exactly: exit_overlay_menu, then
start_run with stake=1 and the full challenge entry. setup_run and
change_to are deliberately omitted — G:start_run forces the deck from
args.challenge.deck.type at game.lua:2037, overriding any selected
back, and reads none of the state setup_run mutates (rationale kept
in a code comment). seed passes through unchanged.
- Normal branch is byte-for-byte unchanged; presence of deck/stake is
now enforced in execute (verbatim error strings preserved).
Observability: G.GAME.challenge (bare id, set at game.lua:2064) is now
exposed in the gamestate — conditionally absent for normal runs (mirrors
the keys-not-names convention: deck→b_red, not "Red Deck"). This is
non-breaking: existing tests only check listed fields.
A static `Challenge` alias (enums.lua) documents the 20 base-game ids;
the OpenRPC spec gains a Challenge schema; api.md documents the new
param and enum. requires_state stays { MENU }; the completion predicate
is unchanged (challenges land in BLIND_SELECT like normal runs).
Closes #214.
Extend tests/lua/endpoints/test_start.py with a TestStartChallenge class covering the new challenge branch. No new fixtures: challenges start from MENU like the existing start tests, and the wrong-state case reuses the existing start/state-BLIND_SELECT fixture. fixtures.json is unchanged. - happy_path (parametrized c_omelette_1, c_jokerless_1, c_mad_world_1): lands in BLIND_SELECT with deck=b_challenge, stake=stake_white, and the challenge id echoed in the gamestate. - with_seed: proves challenge composes freely with seed. - effect_applied (deep): The Omelette starts with 5 Eggs — asserts the challenge actually took effect, not just the flag. - conflict_with_deck / _stake / _both: BAD_REQUEST "cannot be combined". - invalid_id: BAD_REQUEST "Expected a c_* challenge id". - wrong_type: schema-level BAD_REQUEST "must be of type string". The existing TestStartEndpoint / validation / state-requirement tests stay green unchanged — they are the regression guard for the normal path (deck/stake are now schema-optional, enforced in execute with the same verbatim error strings).
Add the "omniscient" entry to the glossary so the property underpinning the API's information model has a single canonical name. It states that the API returns the true rank, suit, and identity of every card at all times, including face-down ones — a `hidden` card is reported as hidden, but its `value` is still its true value. The API performs no information hiding. The term grounds the upcoming `revealed` field: that field only makes sense once it is clear the API is otherwise omniscient, and that hiding information from a consumer is a client-side choice rather than an API behaviour. It flags what to avoid: "cheating" and "god-mode", which misframe a documented property as misconduct. Pinning the term now prevents the same concept from being re-described ad hoc across the API, docs, and future observer-knowledge fields.
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.
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