feat: auto cash-out targets, 1% house edge, terminal aesthetic

Auto cash-out closes a position at exactly the chosen target rather than
the next tick's multiplier, and fires whenever the target is at or below
the crash point. This is the feature that makes the game playable over a
network, where manual timing is at the mercy of latency.

House edge drops from 2% to 1% across crash and scratch. Scratch prize
tables retuned so the published 99% RTP is exact.

Adds docs/API.md: the client uses no private endpoints, so anyone can
write a bot against the same API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-05 16:24:31 +00:00
parent dee3becd47
commit 48a9120fe4
14 changed files with 997 additions and 266 deletions

View File

@@ -190,7 +190,7 @@ func TestPlaceBetDebitsStakeImmediately(t *testing.T) {
f.openBetting()
before, _ := f.ledger.Balance(f.ctx, id)
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 3_000); err != nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 3_000, 0); err != nil {
t.Fatal(err)
}
after, _ := f.ledger.Balance(f.ctx, id)
@@ -204,13 +204,13 @@ func TestCannotBetOutsideBettingWindow(t *testing.T) {
id, pk := f.player("a", 10_000)
// Room starts settled.
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil {
t.Fatal("bet accepted while settled")
}
f.openBetting()
f.startRun()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil {
t.Fatal("bet accepted while running")
}
}
@@ -220,10 +220,10 @@ func TestCannotBetTwiceInOneRound(t *testing.T) {
id, pk := f.player("a", 10_000)
f.openBetting()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil {
t.Fatal(err)
}
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil {
t.Fatal("second bet in the same round was accepted")
}
}
@@ -233,7 +233,7 @@ func TestCannotBetMoreThanBalance(t *testing.T) {
id, pk := f.player("a", 1_000)
f.openBetting()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 50_000); err == nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 50_000, 0); err == nil {
t.Fatal("bet larger than balance was accepted")
}
// And nothing was taken.
@@ -248,7 +248,7 @@ func TestNonPositiveStakesRejected(t *testing.T) {
f.openBetting()
for _, stake := range []int64{0, -1, -5_000} {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake); err == nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err == nil {
t.Fatalf("stake %d was accepted", stake)
}
}
@@ -260,7 +260,7 @@ func TestCashOutOnlyWhileRunning(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 10_000)
f.openBetting()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil {
t.Fatal(err)
}
@@ -278,7 +278,7 @@ func TestCannotCashOutTwice(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 10_000)
f.openBetting()
_ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000)
_ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0)
f.startRun()
f.forceCrashPoint(100)
@@ -307,7 +307,7 @@ func TestCannotCashOutAfterTheCrash(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 10_000)
f.openBetting()
_ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000)
_ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0)
f.startRun()
// Jump past the crash point without letting the loop settle.
@@ -338,7 +338,7 @@ func TestCashedOutPlayerIsPaid(t *testing.T) {
f.openBetting()
const stake = 10_000
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake); err != nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil {
t.Fatal(err)
}
f.startRun()
@@ -376,7 +376,7 @@ func TestPlayerWhoDidNotCashOutGetsNothing(t *testing.T) {
id, pk := f.player("a", 100_000)
f.openBetting()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000); err != nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil {
t.Fatal(err)
}
f.startRun()
@@ -404,7 +404,7 @@ func TestBooksBalanceAcrossAFullRound(t *testing.T) {
var ids []int64
for i := 0; i < 5; i++ {
id, pk := f.player(fmt.Sprintf("p%d", i), 100_000)
if err := f.room.PlaceBet(f.ctx, id, pk, "p", 10_000); err != nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "p", 10_000, 0); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
@@ -447,7 +447,7 @@ func TestCrashPointDerivesFromCommittedSeedAndPlayers(t *testing.T) {
id, pk := f.player("a", 100_000)
f.openBetting()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil {
t.Fatal(err)
}
f.startRun()
@@ -560,7 +560,7 @@ func TestConcurrentBetsAreAllRecorded(t *testing.T) {
wg.Add(1)
go func(i int, a acct) {
defer wg.Done()
errs[i] = f.room.PlaceBet(f.ctx, a.id, a.pk, "c", 5_000)
errs[i] = f.room.PlaceBet(f.ctx, a.id, a.pk, "c", 5_000, 0)
}(i, a)
}
wg.Wait()
@@ -586,7 +586,7 @@ func TestConcurrentCashOutsYieldOne(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
_ = f.room.PlaceBet(f.ctx, id, pk, "a", 5_000)
_ = f.room.PlaceBet(f.ctx, id, pk, "a", 5_000, 0)
f.startRun()
f.forceCrashPoint(100)
@@ -619,7 +619,7 @@ func TestSnapshotReportsCashOutMultiplier(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
_ = f.room.PlaceBet(f.ctx, id, pk, "nick", 5_000)
_ = f.room.PlaceBet(f.ctx, id, pk, "nick", 5_000, 0)
f.startRun()
f.forceCrashPoint(100)
@@ -645,3 +645,167 @@ func TestMultiplierStartsAtOneEachRound(t *testing.T) {
t.Fatalf("multiplier at round open = %s, want %s", got, fixed.One.String())
}
}
/* ---------------- auto cash-out ---------------- */
func TestAutoCashOutFiresAtExactlyTheTarget(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
target := fixed.FromInt(3)
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil {
t.Fatal(err)
}
f.startRun()
f.forceCrashPoint(100) // well above the target, so it must fire
// Advance to the tick that reaches the target.
f.advanceTo(sim.TicksToMultiplier(target))
f.room.mu.Lock()
f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick))
got := f.room.bets[id].CashedOutAt
f.room.mu.Unlock()
if got != target {
t.Fatalf("auto cash-out closed at %v, want exactly %v", got, target)
}
}
func TestAutoCashOutDoesNotFireBelowTarget(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
target := fixed.FromInt(5)
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil {
t.Fatal(err)
}
f.startRun()
f.forceCrashPoint(100)
// One tick short of the target.
f.advanceTo(sim.TicksToMultiplier(target) - 1)
f.room.mu.Lock()
f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick))
got := f.room.bets[id].CashedOutAt
f.room.mu.Unlock()
if got != 0 {
t.Fatalf("auto cash-out fired early at %v", got)
}
}
// A target above the crash point must never pay: the round ends first.
func TestAutoCashOutAboveCrashPointNeverFires(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, fixed.FromInt(50)); err != nil {
t.Fatal(err)
}
f.startRun()
f.forceCrashPoint(3) // crashes well before the target
f.advanceTo(sim.RoundTicks - 1)
f.room.mu.Lock()
f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick))
got := f.room.bets[id].CashedOutAt
f.room.mu.Unlock()
if got != 0 {
t.Fatalf("auto cash-out paid %v on a target above the crash point", got)
}
}
// A target exactly at the crash point is a win, not a loss.
func TestAutoCashOutAtExactlyTheCrashPointPays(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
target := fixed.FromInt(4)
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil {
t.Fatal(err)
}
f.startRun()
f.forceCrashPoint(4)
f.advanceTo(sim.TicksToMultiplier(target))
f.room.mu.Lock()
f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick))
got := f.room.bets[id].CashedOutAt
f.room.mu.Unlock()
if got != target {
t.Fatalf("target equal to the crash point paid %v, want %v", got, target)
}
}
func TestAutoCashOutTargetMustExceedOne(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
for _, target := range []fixed.F{fixed.One, fixed.One / 2} {
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, target); err == nil {
t.Fatalf("target %v was accepted", target)
}
}
// And the stake was never taken.
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 100_000 {
t.Fatalf("balance = %d after rejected bets, want 100000", bal)
}
}
// An auto cash-out must pay the target exactly, not the tick's multiplier.
func TestAutoCashOutPaysTheTargetExactly(t *testing.T) {
f := newFixture(t)
house, _ := f.ledger.AccountByName(f.ctx, "house_pot")
hp, _ := f.player("housefund", 5_000_000)
if _, err := f.ledger.Transfer(f.ctx, hp, house, 5_000_000); err != nil {
t.Fatal(err)
}
id, pk := f.player("a", 100_000)
f.openBetting()
const stake = 10_000
target := fixed.FromInt(3)
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, target); err != nil {
t.Fatal(err)
}
f.startRun()
f.forceCrashPoint(100)
f.advanceTo(sim.TicksToMultiplier(target))
f.room.mu.Lock()
f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick))
f.room.mu.Unlock()
before, _ := f.ledger.Balance(f.ctx, id)
if err := f.room.settle(f.ctx); err != nil {
t.Fatal(err)
}
after, _ := f.ledger.Balance(f.ctx, id)
if got, want := after-before, int64(stake*3); got != want {
t.Fatalf("paid %d, want exactly %d (3.00x of %d)", got, want, stake)
}
}
// A manual cash-out still works when an auto target is set but not yet reached.
func TestManualCashOutOverridesAPendingTarget(t *testing.T) {
f := newFixture(t)
id, pk := f.player("a", 100_000)
f.openBetting()
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, fixed.FromInt(50)); err != nil {
t.Fatal(err)
}
f.startRun()
f.forceCrashPoint(100)
at, err := f.room.CashOut(id)
if err != nil {
t.Fatalf("manual cash-out rejected: %v", err)
}
if at >= fixed.FromInt(50) {
t.Fatalf("manual cash-out returned %v, expected the current multiplier", at)
}
}