fix!(lua.endpoints): cash_out fires before round-eval rows commit — rewards under-collected, sometimes $0
- Dominant language
- Python
- Stars
- 72
- Forks
- 18
- PR merge metrics
- No merged PRs in 30d
Description
## Description
Calling `cash_out` as soon as the game reaches `ROUND_EVAL` collects less money
than the game owes — usually short **exactly one blind reward** ($3/$4/$5), and
**$0** in the worst case. A human cannot reproduce this: the game only spawns the
Cash Out button after the eval rows have finished paying out, but the endpoint
bypasses that gate.
## Root Cause
`src/lua/endpoints/cash_out.lua`:
- `requires_state = { G.STATES.ROUND_EVAL }` (line 22) passes the moment the
state flips, while the dollar rows (blind reward, interest, per-joker payouts)
are still being queued and committed by the event manager.
- `execute` then calls `G.FUNCS.cash_out({ config = {} })` immediately (line 28).
The endpoint carefully waits for the SHOP transition on the *response* side, but
does not wait for eval completion on the *entry* side.
## Evidence
Found with a differential-replay harness that drives a vanilla-faithful simulator
and balatrobot with identical action traces and deep-diffs the full gamestate
after every action (~1,200 organic games).
- Signature: after cash-out, live money == expected money minus exactly one
blind reward. Confirmed 4× before we added a workaround.
- Partial commits happen: one case committed interest but dropped the $5 boss
reward (seed `LSOYQISY`).
- Zero-payout extreme: when `hands_left == 0` and there is no interest (the
payout is only the reward row), cash-out commits **$0**. Reproduced
deterministically across bosses (Psychic, Pillar) and across game sessions
(seeds `LSAZ99YB`, `LS88BC4Z`).
- Workaround that eliminates it completely: a flat 12s wait before calling
`cash_out` (normal speed, `--no-audio`).
The under-collection is irreversible once collected — the money is simply gone
for the rest of the run.
## Impact
Any bot that auto-cashes on seeing `ROUND_EVAL` is systematically poorer than
the game intends. balatrollm does exactly this (`bot.py`:
`case "ROUND_EVAL": ... call("cash_out")`), so BalatroBench run economies — and
any result that turns on being a few dollars short in the shop — are affected.
## Suggested Fix
Gate execution the way the game gates the button: add a condition event that
waits until the round-eval UI has finished creating its rows (e.g. the Cash Out
button exists), and only then call `G.FUNCS.cash_out`. This mirrors the
endpoint's existing SHOP-side wait.
We can provide full repro bundles (action trace + both gamestates + field diff
at the divergence step) for any of the seeds above.
Contributor guide
Assessment
This issue has not been assessed yet.