balatrobot 1.3.0__tar.gz → 1.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {balatrobot-1.3.0 → balatrobot-1.3.3}/.github/workflows/release_please.yml +0 -17
  2. balatrobot-1.3.3/.github/workflows/release_pypi.yml +53 -0
  3. {balatrobot-1.3.0 → balatrobot-1.3.3}/CHANGELOG.md +22 -0
  4. {balatrobot-1.3.0 → balatrobot-1.3.3}/PKG-INFO +1 -1
  5. {balatrobot-1.3.0 → balatrobot-1.3.3}/balatrobot.json +1 -1
  6. {balatrobot-1.3.0 → balatrobot-1.3.3}/pyproject.toml +1 -1
  7. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/__init__.py +1 -1
  8. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/buy.lua +9 -1
  9. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/next_round.lua +20 -6
  10. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/pack.lua +27 -10
  11. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/utils/openrpc.json +1 -1
  12. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/fixtures/fixtures.json +146 -0
  13. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_buy.py +29 -1
  14. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_pack.py +63 -0
  15. {balatrobot-1.3.0 → balatrobot-1.3.3}/uv.lock +1 -1
  16. balatrobot-1.3.0/.github/workflows/release_pypi.yml +0 -28
  17. {balatrobot-1.3.0 → balatrobot-1.3.3}/.claude/settings.json +0 -0
  18. {balatrobot-1.3.0 → balatrobot-1.3.3}/.editorconfig +0 -0
  19. {balatrobot-1.3.0 → balatrobot-1.3.3}/.github/workflows/code_quality.yml +0 -0
  20. {balatrobot-1.3.0 → balatrobot-1.3.3}/.github/workflows/commit_lint.yml +0 -0
  21. {balatrobot-1.3.0 → balatrobot-1.3.3}/.github/workflows/deploy_docs.yml +0 -0
  22. {balatrobot-1.3.0 → balatrobot-1.3.3}/.gitignore +0 -0
  23. {balatrobot-1.3.0 → balatrobot-1.3.3}/.mdformat.toml +0 -0
  24. {balatrobot-1.3.0 → balatrobot-1.3.3}/.mux/init +0 -0
  25. {balatrobot-1.3.0 → balatrobot-1.3.3}/.mux/mcp.jsonc +0 -0
  26. {balatrobot-1.3.0 → balatrobot-1.3.3}/.mux/tool_env +0 -0
  27. {balatrobot-1.3.0 → balatrobot-1.3.3}/.mux/tool_post +0 -0
  28. {balatrobot-1.3.0 → balatrobot-1.3.3}/.python-version +0 -0
  29. {balatrobot-1.3.0 → balatrobot-1.3.3}/CLAUDE.md +0 -0
  30. {balatrobot-1.3.0 → balatrobot-1.3.3}/LICENSE +0 -0
  31. {balatrobot-1.3.0 → balatrobot-1.3.3}/Makefile +0 -0
  32. {balatrobot-1.3.0 → balatrobot-1.3.3}/README.md +0 -0
  33. {balatrobot-1.3.0 → balatrobot-1.3.3}/balatrobot.lua +0 -0
  34. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/api.md +0 -0
  35. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/assets/balatrobench.svg +0 -0
  36. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/assets/balatrobot-white.svg +0 -0
  37. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/assets/balatrobot.svg +0 -0
  38. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/assets/balatrollm.svg +0 -0
  39. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/cli.md +0 -0
  40. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/contributing.md +0 -0
  41. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/example-bot.md +0 -0
  42. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/index.md +0 -0
  43. {balatrobot-1.3.0 → balatrobot-1.3.3}/docs/installation.md +0 -0
  44. {balatrobot-1.3.0 → balatrobot-1.3.3}/mkdocs.yml +0 -0
  45. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/__main__.py +0 -0
  46. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/cli.py +0 -0
  47. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/config.py +0 -0
  48. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/manager.py +0 -0
  49. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/platforms/__init__.py +0 -0
  50. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/platforms/base.py +0 -0
  51. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/platforms/macos.py +0 -0
  52. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/platforms/native.py +0 -0
  53. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/balatrobot/platforms/windows.py +0 -0
  54. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/core/dispatcher.lua +0 -0
  55. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/core/server.lua +0 -0
  56. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/core/validator.lua +0 -0
  57. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/add.lua +0 -0
  58. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/cash_out.lua +0 -0
  59. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/discard.lua +0 -0
  60. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/gamestate.lua +0 -0
  61. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/health.lua +0 -0
  62. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/load.lua +0 -0
  63. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/menu.lua +0 -0
  64. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/play.lua +0 -0
  65. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/rearrange.lua +0 -0
  66. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/reroll.lua +0 -0
  67. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/save.lua +0 -0
  68. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/screenshot.lua +0 -0
  69. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/select.lua +0 -0
  70. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/sell.lua +0 -0
  71. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/set.lua +0 -0
  72. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/skip.lua +0 -0
  73. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/start.lua +0 -0
  74. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/tests/echo.lua +0 -0
  75. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/tests/endpoint.lua +0 -0
  76. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/tests/error.lua +0 -0
  77. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/tests/state.lua +0 -0
  78. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/tests/validation.lua +0 -0
  79. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/endpoints/use.lua +0 -0
  80. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/settings.lua +0 -0
  81. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/utils/debugger.lua +0 -0
  82. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/utils/enums.lua +0 -0
  83. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/utils/errors.lua +0 -0
  84. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/utils/gamestate.lua +0 -0
  85. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/utils/logger.lua +0 -0
  86. {balatrobot-1.3.0 → balatrobot-1.3.3}/src/lua/utils/types.lua +0 -0
  87. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/__init__.py +0 -0
  88. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/cli/__init__.py +0 -0
  89. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/cli/conftest.py +0 -0
  90. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/cli/test_config.py +0 -0
  91. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/cli/test_integration.py +0 -0
  92. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/cli/test_manager.py +0 -0
  93. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/cli/test_platforms.py +0 -0
  94. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/conftest.py +0 -0
  95. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/fixtures/generate.py +0 -0
  96. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/__init__.py +0 -0
  97. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/conftest.py +0 -0
  98. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/core/__init__.py +0 -0
  99. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/core/test_dispatcher.py +0 -0
  100. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/core/test_server.py +0 -0
  101. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/core/test_validator.py +0 -0
  102. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/__init__.py +0 -0
  103. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_add.py +0 -0
  104. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_cash_out.py +0 -0
  105. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_discard.py +0 -0
  106. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_gamestate.py +0 -0
  107. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_health.py +0 -0
  108. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_load.py +0 -0
  109. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_menu.py +0 -0
  110. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_next_round.py +0 -0
  111. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_play.py +0 -0
  112. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_rearrange.py +0 -0
  113. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_reroll.py +0 -0
  114. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_save.py +0 -0
  115. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_screenshot.py +0 -0
  116. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_select.py +0 -0
  117. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_sell.py +0 -0
  118. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_set.py +0 -0
  119. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_skip.py +0 -0
  120. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_start.py +0 -0
  121. {balatrobot-1.3.0 → balatrobot-1.3.3}/tests/lua/endpoints/test_use.py +0 -0
@@ -69,20 +69,3 @@ jobs:
69
69
  else
70
70
  echo "No changes to version files"
71
71
  fi
72
- - name: Notify consumer repo of new balatrobot release
73
- if: ${{ steps.release.outputs.release_created }}
74
- env:
75
- BALATROLLM_REPO: "coder/balatrollm"
76
- BALATROLLM_TOKEN: ${{ secrets.BALATROLLM_TOKEN }}
77
- BALATRO_REPO: "S1M0N38/balatro"
78
- BALATRO_TOKEN: ${{ secrets.BALATRO_TOKEN }}
79
- run: |
80
- VERSION="${{ steps.release.outputs.version }}"
81
- curl -X POST -H "Accept: application/vnd.github+json" \
82
- -H "Authorization: token $BALATROLLM_TOKEN" \
83
- https://api.github.com/repos/$BALATROLLM_REPO/dispatches \
84
- -d "{\"event_type\":\"balatrobot_release\",\"client_payload\":{\"version\":\"$VERSION\"}}"
85
- curl -X POST -H "Accept: application/vnd.github+json" \
86
- -H "Authorization: token $BALATRO_TOKEN" \
87
- https://api.github.com/repos/$BALATRO_REPO/dispatches \
88
- -d "{\"event_type\":\"balatrobot_release\",\"client_payload\":{\"version\":\"$VERSION\"}}"
@@ -0,0 +1,53 @@
1
+ name: Release PyPI
2
+ on:
3
+ push:
4
+ tags:
5
+ - v*
6
+ workflow_dispatch:
7
+ jobs:
8
+ pypi:
9
+ name: Publish to PyPI
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ permissions:
14
+ id-token: write
15
+ contents: read
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@v5
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v7
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version-file: ".python-version"
25
+ - name: Build
26
+ run: uv build
27
+ - name: Publish
28
+ run: uv publish
29
+ - name: Wait for PyPI availability and notify balatrollm
30
+ env:
31
+ BALATROLLM_TOKEN: ${{ secrets.BALATROLLM_TOKEN }}
32
+ run: |
33
+ VERSION="${GITHUB_REF_NAME#v}"
34
+ echo "Waiting for version $VERSION to appear on PyPI..."
35
+
36
+ # Poll PyPI until version is available (max 5 minutes)
37
+ for i in {1..30}; do
38
+ PYPI_VERSION=$(curl -s https://pypi.org/pypi/balatrobot/json | jq -r .info.version)
39
+ if [ "$PYPI_VERSION" = "$VERSION" ]; then
40
+ echo "✓ Version $VERSION is available on PyPI"
41
+ curl -s -X POST -H "Accept: application/vnd.github+json" \
42
+ -H "Authorization: token $BALATROLLM_TOKEN" \
43
+ https://api.github.com/repos/coder/balatrollm/dispatches \
44
+ -d "{\"event_type\":\"balatrobot_release\",\"client_payload\":{\"version\":\"$VERSION\"}}"
45
+ echo "✓ Dispatched to balatrollm"
46
+ exit 0
47
+ fi
48
+ echo "Attempt $i/30: PyPI shows $PYPI_VERSION, waiting for $VERSION..."
49
+ sleep 10
50
+ done
51
+
52
+ echo "ERROR: Version $VERSION not available on PyPI after 5 minutes"
53
+ exit 1
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.3](https://github.com/coder/balatrobot/compare/v1.3.2...v1.3.3) (2026-01-12)
4
+
5
+
6
+ ### Continuous Integration
7
+
8
+ * **release:** move release notification to pypi workflow ([2274781](https://github.com/coder/balatrobot/commit/2274781d66cdc32a09f618b5058542dc1e4dc2b4))
9
+
10
+ ## [1.3.2](https://github.com/coder/balatrobot/compare/v1.3.1...v1.3.2) (2026-01-12)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **lua.endpoint:** add nil checks for race condition in `next_round` endpoint ([ce08dcc](https://github.com/coder/balatrobot/commit/ce08dcc5a13b102ecd0ac56631791903487f7fea))
16
+ * **lua.endpoints:** fix error message for invalid voucher/pack index in buy ([4af3d39](https://github.com/coder/balatrobot/commit/4af3d39c31a2575c7986c4cda81618ee5aa652fe))
17
+
18
+ ## [1.3.1](https://github.com/coder/balatrobot/compare/v1.3.0...v1.3.1) (2026-01-11)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * **lua.endpoints:** fix `pack` with mega packs with double target selection ([ba3e270](https://github.com/coder/balatrobot/commit/ba3e2700bb31ec90c16cbc1316ee2dc5771dbf7e))
24
+
3
25
  ## [1.3.0](https://github.com/coder/balatrobot/compare/v1.2.2...v1.3.0) (2026-01-06)
4
26
 
5
27
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: balatrobot
3
- Version: 1.3.0
3
+ Version: 1.3.3
4
4
  Summary: API for developing Balatro bots
5
5
  Project-URL: Homepage, https://github.com/coder/balatrobot
6
6
  Project-URL: Issues, https://github.com/coder/balatrobot/issues
@@ -15,7 +15,7 @@
15
15
  "badge_colour": "4CAF50",
16
16
  "badge_text_colour": "FFFFFF",
17
17
  "display_name": "BB",
18
- "version": "1.2.2",
18
+ "version": "1.3.2",
19
19
  "dependencies": [
20
20
  "Steamodded (>=1.*)"
21
21
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "balatrobot"
3
- version = "1.3.0"
3
+ version = "1.3.3"
4
4
  description = "API for developing Balatro bots"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -3,5 +3,5 @@
3
3
  from balatrobot.config import Config
4
4
  from balatrobot.manager import BalatroInstance
5
5
 
6
- __version__ = "1.3.0"
6
+ __version__ = "1.3.3"
7
7
  __all__ = ["BalatroInstance", "Config", "__version__"]
@@ -101,8 +101,16 @@ return {
101
101
 
102
102
  -- Validate card index is in range
103
103
  if not area.cards[pos] then
104
+ local msg
105
+ if args.card then
106
+ msg = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count
107
+ elseif args.voucher then
108
+ msg = "Voucher index out of range. Index: " .. args.voucher .. ", Available: " .. area.count
109
+ else
110
+ msg = "Pack index out of range. Index: " .. args.pack .. ", Available: " .. area.count
111
+ end
104
112
  send_response({
105
- message = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count,
113
+ message = msg,
106
114
  name = BB_ERROR_NAMES.BAD_REQUEST,
107
115
  })
108
116
  return
@@ -32,14 +32,28 @@ return {
32
32
  trigger = "condition",
33
33
  blocking = false,
34
34
  func = function()
35
- local blind_pane = G.blind_select_opts[string.lower(G.GAME.blind_on_deck)]
35
+ -- Wait for state transition and UI to be fully initialized
36
+ if G.STATE ~= G.STATES.BLIND_SELECT then
37
+ return false
38
+ end
39
+ if not G.blind_select_opts then
40
+ return false
41
+ end
42
+
43
+ local blind_key = string.lower(G.GAME.blind_on_deck)
44
+ local blind_pane = G.blind_select_opts[blind_key]
45
+ if not blind_pane then
46
+ return false
47
+ end
48
+
36
49
  local select_button = blind_pane:get_UIE_by_ID("select_blind_button")
37
- local done = G.STATE == G.STATES.BLIND_SELECT and select_button ~= nil
38
- if done then
39
- sendDebugMessage("Return next_round() - reached BLIND_SELECT state", "BB.ENDPOINTS")
40
- send_response(BB_GAMESTATE.get_gamestate())
50
+ if not select_button then
51
+ return false
41
52
  end
42
- return done
53
+
54
+ sendDebugMessage("Return next_round() - reached BLIND_SELECT state", "BB.ENDPOINTS")
55
+ send_response(BB_GAMESTATE.get_gamestate())
56
+ return true
43
57
  end,
44
58
  }))
45
59
  end,
@@ -196,12 +196,12 @@ return {
196
196
 
197
197
  -- Highlight the target cards in hand
198
198
  if args.targets and #args.targets > 0 then
199
- -- Clear existing highlights
200
- for _, hand_card in ipairs(G.hand.cards) do
201
- hand_card.highlighted = false
199
+ -- Clear existing highlights using proper CardArea method
200
+ for i = #G.hand.highlighted, 1, -1 do
201
+ G.hand:remove_from_highlighted(G.hand.highlighted[i], true)
202
202
  end
203
203
 
204
- -- Highlight target cards
204
+ -- Highlight target cards using proper CardArea method
205
205
  for _, target_idx in ipairs(args.targets) do
206
206
  local hand_pos = target_idx + 1 -- Convert 0-based to 1-based
207
207
  if not G.hand.cards[hand_pos] then
@@ -211,8 +211,7 @@ return {
211
211
  })
212
212
  return true
213
213
  end
214
- G.hand.cards[hand_pos].highlighted = true
215
- G.hand.highlighted[#G.hand.highlighted + 1] = G.hand.cards[hand_pos]
214
+ G.hand:add_to_highlighted(G.hand.cards[hand_pos], true)
216
215
  end
217
216
  end
218
217
  end
@@ -320,17 +319,35 @@ return {
320
319
  trigger = "condition",
321
320
  blocking = false,
322
321
  func = function()
323
- -- Calculate expected hand size
324
- -- If deck has fewer cards than hand limit, hand will only have deck_size cards
322
+ -- Wait for state transition to complete (ensures hand is fully dealt)
323
+ if not G.STATE_COMPLETE then
324
+ return false
325
+ end
326
+
327
+ -- Calculate expected hand size for initial load
328
+ -- After cards are destroyed (mega packs), hand may have fewer cards
325
329
  local hand_limit = G.hand.config and G.hand.config.card_limit or 8
326
330
  local deck_size = G.deck and G.deck.config and G.deck.config.card_count or 52
327
331
  local expected_hand_size = math.min(deck_size, hand_limit)
328
332
 
329
- -- Wait for hand to be fully loaded and positioned
333
+ -- Calculate minimum required cards based on target indices
334
+ local min_required = 1
335
+ if args.targets and #args.targets > 0 then
336
+ for _, target_idx in ipairs(args.targets) do
337
+ local required = target_idx + 1 -- 0-based to 1-based
338
+ if required > min_required then
339
+ min_required = required
340
+ end
341
+ end
342
+ end
343
+
344
+ -- Wait for hand to be ready:
345
+ -- - At least expected_hand_size cards (initial load), OR
346
+ -- - At least min_required cards (for mega packs after cards destroyed)
330
347
  local hand_ready = G.hand
331
348
  and not G.hand.REMOVED
332
349
  and G.hand.cards
333
- and #G.hand.cards == expected_hand_size
350
+ and (#G.hand.cards >= expected_hand_size or #G.hand.cards >= min_required)
334
351
  and G.hand.T -- Table area exists
335
352
  and G.hand.T.x -- Positioned
336
353
 
@@ -3,7 +3,7 @@
3
3
  "info": {
4
4
  "title": "BalatroBot API",
5
5
  "description": "JSON-RPC 2.0 API for Balatro bot development. This API allows external clients to control the Balatro game, query game state, and execute actions through an HTTP server.",
6
- "version": "1.2.2",
6
+ "version": "1.3.2",
7
7
  "license": {
8
8
  "name": "MIT"
9
9
  }
@@ -1612,6 +1612,152 @@
1612
1612
  "pack": 1
1613
1613
  }
1614
1614
  }
1615
+ ],
1616
+ "seed-VEBROR8--state-SMODS_BOOSTER_OPENED--pack.key-p_arcana_mega_1": [
1617
+ {
1618
+ "method": "menu",
1619
+ "params": {}
1620
+ },
1621
+ {
1622
+ "method": "start",
1623
+ "params": {
1624
+ "deck": "RED",
1625
+ "stake": "WHITE",
1626
+ "seed": "VEBROR8"
1627
+ }
1628
+ },
1629
+ {
1630
+ "method": "select",
1631
+ "params": {}
1632
+ },
1633
+ {
1634
+ "method": "set",
1635
+ "params": {
1636
+ "chips": 1000,
1637
+ "money": 100
1638
+ }
1639
+ },
1640
+ {
1641
+ "method": "play",
1642
+ "params": {
1643
+ "cards": [
1644
+ 0
1645
+ ]
1646
+ }
1647
+ },
1648
+ {
1649
+ "method": "cash_out",
1650
+ "params": {}
1651
+ },
1652
+ {
1653
+ "method": "buy",
1654
+ "params": {
1655
+ "pack": 0
1656
+ }
1657
+ },
1658
+ {
1659
+ "method": "pack",
1660
+ "params": {
1661
+ "skip": true
1662
+ }
1663
+ },
1664
+ {
1665
+ "method": "buy",
1666
+ "params": {
1667
+ "pack": 0
1668
+ }
1669
+ },
1670
+ {
1671
+ "method": "pack",
1672
+ "params": {
1673
+ "skip": true
1674
+ }
1675
+ },
1676
+ {
1677
+ "method": "add",
1678
+ "params": {
1679
+ "key": "p_arcana_mega_1"
1680
+ }
1681
+ },
1682
+ {
1683
+ "method": "buy",
1684
+ "params": {
1685
+ "pack": 0
1686
+ }
1687
+ }
1688
+ ],
1689
+ "seed-7IDNRIV--state-SMODS_BOOSTER_OPENED--pack.cards[2].key-c_black_hole": [
1690
+ {
1691
+ "method": "menu",
1692
+ "params": {}
1693
+ },
1694
+ {
1695
+ "method": "start",
1696
+ "params": {
1697
+ "deck": "RED",
1698
+ "stake": "WHITE",
1699
+ "seed": "7IDNRIV"
1700
+ }
1701
+ },
1702
+ {
1703
+ "method": "select",
1704
+ "params": {}
1705
+ },
1706
+ {
1707
+ "method": "set",
1708
+ "params": {
1709
+ "chips": 1000,
1710
+ "money": 100
1711
+ }
1712
+ },
1713
+ {
1714
+ "method": "play",
1715
+ "params": {
1716
+ "cards": [
1717
+ 0
1718
+ ]
1719
+ }
1720
+ },
1721
+ {
1722
+ "method": "cash_out",
1723
+ "params": {}
1724
+ },
1725
+ {
1726
+ "method": "buy",
1727
+ "params": {
1728
+ "pack": 0
1729
+ }
1730
+ },
1731
+ {
1732
+ "method": "pack",
1733
+ "params": {
1734
+ "skip": true
1735
+ }
1736
+ },
1737
+ {
1738
+ "method": "buy",
1739
+ "params": {
1740
+ "pack": 0
1741
+ }
1742
+ },
1743
+ {
1744
+ "method": "pack",
1745
+ "params": {
1746
+ "skip": true
1747
+ }
1748
+ },
1749
+ {
1750
+ "method": "add",
1751
+ "params": {
1752
+ "key": "p_celestial_mega_1"
1753
+ }
1754
+ },
1755
+ {
1756
+ "method": "buy",
1757
+ "params": {
1758
+ "pack": 0
1759
+ }
1760
+ }
1615
1761
  ]
1616
1762
  },
1617
1763
  "sell": {
@@ -49,7 +49,7 @@ class TestBuyEndpoint:
49
49
  "No jokers/consumables/cards in the shop. Reroll to restock the shop",
50
50
  )
51
51
 
52
- def test_buy_invalid_index(self, client: httpx.Client) -> None:
52
+ def test_buy_invalid_card_index(self, client: httpx.Client) -> None:
53
53
  """Test buy endpoint with invalid card index."""
54
54
  gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
55
55
  assert gamestate["state"] == "SHOP"
@@ -60,6 +60,34 @@ class TestBuyEndpoint:
60
60
  "Card index out of range. Index: 999, Available cards: 2",
61
61
  )
62
62
 
63
+ def test_buy_invalid_voucher_index(self, client: httpx.Client) -> None:
64
+ """Test buy endpoint with invalid voucher index."""
65
+ gamestate = load_fixture(
66
+ client, "buy", "state-SHOP--voucher.cards[0].set-VOUCHER"
67
+ )
68
+ assert gamestate["state"] == "SHOP"
69
+ assert gamestate["vouchers"]["cards"][0]["set"] == "VOUCHER"
70
+ assert_error_response(
71
+ api(client, "buy", {"voucher": 999}),
72
+ "BAD_REQUEST",
73
+ "Voucher index out of range. Index: 999, Available: 1",
74
+ )
75
+
76
+ def test_buy_invalid_pack_index(self, client: httpx.Client) -> None:
77
+ """Test buy endpoint with invalid pack index."""
78
+ gamestate = load_fixture(
79
+ client,
80
+ "buy",
81
+ "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack",
82
+ )
83
+ assert gamestate["state"] == "SHOP"
84
+ assert gamestate["packs"]["cards"][0]["label"] == "Buffoon Pack"
85
+ assert_error_response(
86
+ api(client, "buy", {"pack": 999}),
87
+ "BAD_REQUEST",
88
+ "Pack index out of range. Index: 999, Available: 2",
89
+ )
90
+
63
91
  def test_buy_insufficient_funds(self, client: httpx.Client) -> None:
64
92
  """Test buy endpoint when player has insufficient funds."""
65
93
  gamestate = load_fixture(client, "buy", "state-SHOP--money-0")
@@ -372,6 +372,37 @@ class TestPackEndpointSelection:
372
372
  after = assert_gamestate_response(result)
373
373
  assert before["jokers"]["count"] + 1 == after["jokers"]["count"]
374
374
 
375
+ def test_pack_celestial_black_hole(self, client: httpx.Client) -> None:
376
+ """Test selecting Black Hole from a celestial mega pack levels up all hands.
377
+
378
+ Black Hole is a special planet card that levels up all poker hands by 1.
379
+ Mega packs allow 2 selections, so we also select a second planet card.
380
+ """
381
+ load_fixture(
382
+ client,
383
+ "pack",
384
+ "seed-7IDNRIV--state-SMODS_BOOSTER_OPENED--pack.cards[2].key-c_black_hole",
385
+ )
386
+ before = api(client, "gamestate", {})["result"]
387
+
388
+ # First selection: Black Hole at index 2
389
+ result = api(client, "pack", {"card": 2})
390
+ after_first = assert_gamestate_response(result, state="SMODS_BOOSTER_OPENED")
391
+
392
+ # Black Hole levels up ALL hands by 1
393
+ for hand_name in before["hands"]:
394
+ assert (
395
+ after_first["hands"][hand_name]["level"]
396
+ == before["hands"][hand_name]["level"] + 1
397
+ )
398
+
399
+ # Second selection: any planet card at index 0
400
+ result = api(client, "pack", {"card": 0})
401
+ after_second = assert_gamestate_response(result, state="SHOP")
402
+
403
+ # Pack should be closed after second selection
404
+ assert "pack" not in after_second
405
+
375
406
 
376
407
  # =============================================================================
377
408
  # Mega Pack Multi-Selection Tests
@@ -401,6 +432,38 @@ class TestPackEndpointMegaPack:
401
432
  gamestate = assert_gamestate_response(result, state="SHOP")
402
433
  assert "pack" not in gamestate
403
434
 
435
+ def test_mega_pack_both_selections_with_targets(self, client: httpx.Client) -> None:
436
+ """Test mega pack where both selections require targets.
437
+
438
+ Pack contents (seed VEBROR8):
439
+ [0] c_wheel_of_fortune
440
+ [1] c_sun
441
+ [2] c_star
442
+ [3] c_hanged_man - requires 2 targets (first selection)
443
+ [4] c_chariot - requires 1 target (second selection)
444
+ """
445
+ load_fixture(
446
+ client,
447
+ "pack",
448
+ "seed-VEBROR8--state-SMODS_BOOSTER_OPENED--pack.key-p_arcana_mega_1",
449
+ )
450
+
451
+ result = api(client, "pack", {"card": 3, "targets": [0, 1]})
452
+ gamestate = assert_gamestate_response(result, state="SMODS_BOOSTER_OPENED")
453
+
454
+ # After first selection, pack should still be open (mega packs allow 2 selections)
455
+ # The Hanged Man was removed, so cards shifted:
456
+ # [0] c_wheel_of_fortune, [1] c_sun, [2] c_star, [3] c_chariot
457
+ assert "pack" in gamestate
458
+ assert len(gamestate["pack"]["cards"]) == 4
459
+
460
+ # Second selection: card index 3 is now c_chariot (requires 1 target)
461
+ result = api(client, "pack", {"card": 3, "targets": [0]})
462
+ gamestate = assert_gamestate_response(result, state="SHOP")
463
+
464
+ # After second selection, pack should be closed
465
+ assert "pack" not in gamestate
466
+
404
467
 
405
468
  # =============================================================================
406
469
  # Skip Tests
@@ -48,7 +48,7 @@ wheels = [
48
48
 
49
49
  [[package]]
50
50
  name = "balatrobot"
51
- version = "1.2.2"
51
+ version = "1.3.2"
52
52
  source = { editable = "." }
53
53
  dependencies = [
54
54
  { name = "httpx" },
@@ -1,28 +0,0 @@
1
- name: Release PyPI
2
- on:
3
- push:
4
- tags:
5
- - v*
6
- workflow_dispatch:
7
- jobs:
8
- pypi:
9
- name: Publish to PyPI
10
- runs-on: ubuntu-latest
11
- environment:
12
- name: pypi
13
- permissions:
14
- id-token: write
15
- contents: read
16
- steps:
17
- - name: Checkout
18
- uses: actions/checkout@v5
19
- - name: Install uv
20
- uses: astral-sh/setup-uv@v7
21
- - name: Set up Python
22
- uses: actions/setup-python@v5
23
- with:
24
- python-version-file: ".python-version"
25
- - name: Build
26
- run: uv build
27
- - name: Publish
28
- run: uv publish
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes