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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user