coder / coder/balatrobot

fix!(lua.endpoints): cash_out fires before round-eval rows commit — rewards under-collected, sometimes $0

Open
#231 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.