Skip to content

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

Description

@Khetnen

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions