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:
@@ -50,6 +50,12 @@ type Bet struct {
|
||||
StakeMsat int64
|
||||
CashedOutAt fixed.F // zero until they cash out
|
||||
PayoutMsat int64
|
||||
|
||||
// AutoCashOutAt is an optional target set before the round starts. When
|
||||
// the multiplier reaches it the position closes automatically at exactly
|
||||
// that value — not at whatever the next tick happens to show — so the
|
||||
// player gets the number they chose. Zero means no target.
|
||||
AutoCashOutAt fixed.F
|
||||
}
|
||||
|
||||
// Snapshot is what clients render. It carries the seed inputs so a client can
|
||||
@@ -75,6 +81,8 @@ type Player struct {
|
||||
StakeMsat int64 `json:"stake_msat"`
|
||||
CashedOut string `json:"cashed_out,omitempty"`
|
||||
PayoutMsat int64 `json:"payout_msat"`
|
||||
// Auto is true when the position closed on its own target rather than a tap.
|
||||
Auto bool `json:"auto,omitempty"`
|
||||
}
|
||||
|
||||
// Room runs one game's round loop.
|
||||
@@ -188,6 +196,10 @@ func (r *Room) step(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
r.tick++
|
||||
reached := sim.MultiplierAt(r.tick)
|
||||
// Close any positions whose target has been met. This happens before
|
||||
// the crash check so a target at or below the crash point always pays,
|
||||
// regardless of where tick boundaries happen to fall.
|
||||
r.triggerAutoCashOutsLocked(reached)
|
||||
// A crash point beyond what the curve expresses would otherwise never
|
||||
// be reached, so the tick ceiling also ends the round.
|
||||
crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks
|
||||
@@ -321,12 +333,35 @@ func (r *Room) settle(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerAutoCashOutsLocked closes positions whose target the multiplier has
|
||||
// reached. Callers must hold the lock.
|
||||
//
|
||||
// A target above the crash point never fires: the round is already over at
|
||||
// that value. A target at or below it always fires, at exactly the target.
|
||||
func (r *Room) triggerAutoCashOutsLocked(reached fixed.F) {
|
||||
for _, b := range r.bets {
|
||||
if b.CashedOutAt != 0 || b.AutoCashOutAt == 0 {
|
||||
continue
|
||||
}
|
||||
if b.AutoCashOutAt > r.crashPoint {
|
||||
continue // the round ends before this target is reached
|
||||
}
|
||||
if reached >= b.AutoCashOutAt {
|
||||
b.CashedOutAt = b.AutoCashOutAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PlaceBet takes a stake during the betting window. The stake moves to the
|
||||
// house immediately, so a player can never bet money they do not have.
|
||||
func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64) error {
|
||||
func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64, autoCashOutAt fixed.F) error {
|
||||
if stakeMsat <= 0 {
|
||||
return ledger.ErrNonPositiveAmount
|
||||
}
|
||||
// A target at or below 1.0 would close instantly for no gain.
|
||||
if autoCashOutAt != 0 && autoCashOutAt <= fixed.One {
|
||||
return fmt.Errorf("auto cash-out target must be above 1.00")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if r.state != StateBetting {
|
||||
@@ -367,6 +402,7 @@ func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nic
|
||||
r.bets[accountID] = &Bet{
|
||||
AccountID: accountID, Pubkey: pubkey,
|
||||
Nickname: nickname, StakeMsat: stakeMsat,
|
||||
AutoCashOutAt: autoCashOutAt,
|
||||
}
|
||||
r.order = append(r.order, pubkey)
|
||||
r.mu.Unlock()
|
||||
@@ -423,6 +459,7 @@ func (r *Room) Snapshot() Snapshot {
|
||||
}
|
||||
if b.CashedOutAt != 0 {
|
||||
p.CashedOut = b.CashedOutAt.String()
|
||||
p.Auto = b.AutoCashOutAt != 0 && b.CashedOutAt == b.AutoCashOutAt
|
||||
}
|
||||
players = append(players, p)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,11 +189,11 @@ var Catalog = []Ticket{
|
||||
Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.",
|
||||
Cells: 9,
|
||||
// Weights are out of 1,000,000 and sum to it exactly. The weighted
|
||||
// payout sums to 9.8e9, which is an RTP of exactly 98%.
|
||||
// payout sums to 9.9e9, which is an RTP of exactly 99%.
|
||||
Tiers: []Tier{
|
||||
{Name: "No win", Weight: 463_400, PayoutBP: 0},
|
||||
{Name: "No win", Weight: 458_400, PayoutBP: 0},
|
||||
{Name: "Stake back", Weight: 350_000, PayoutBP: 10_000},
|
||||
{Name: "Double", Weight: 150_000, PayoutBP: 20_000},
|
||||
{Name: "Double", Weight: 155_000, PayoutBP: 20_000},
|
||||
{Name: "Five times", Weight: 30_000, PayoutBP: 50_000},
|
||||
{Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000},
|
||||
{Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000},
|
||||
@@ -204,13 +204,13 @@ var Catalog = []Ticket{
|
||||
Name: "Singularity",
|
||||
Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.",
|
||||
Cells: 6,
|
||||
// Same 98% RTP as Nebula Nine, but concentrated in the rare tiers:
|
||||
// Same 99% RTP as Nebula Nine, but concentrated in the rare tiers:
|
||||
// you lose far more often, and the top prize is 500x.
|
||||
Tiers: []Tier{
|
||||
{Name: "No win", Weight: 866_530, PayoutBP: 0},
|
||||
{Name: "No win", Weight: 865_530, PayoutBP: 0},
|
||||
{Name: "Stake back", Weight: 60_000, PayoutBP: 10_000},
|
||||
{Name: "Triple", Weight: 45_000, PayoutBP: 30_000},
|
||||
{Name: "Ten times", Weight: 25_000, PayoutBP: 100_000},
|
||||
{Name: "Ten times", Weight: 26_000, PayoutBP: 100_000},
|
||||
{Name: "Hundred times", Weight: 3_000, PayoutBP: 1_000_000},
|
||||
{Name: "Singularity", Weight: 470, PayoutBP: 5_000_000},
|
||||
},
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestCatalogIsCoherent(t *testing.T) {
|
||||
func TestPublishedRTPIsHonest(t *testing.T) {
|
||||
for _, ticket := range scratch.Catalog {
|
||||
rtp := ticket.RTPBasisPoints()
|
||||
if rtp != 9800 {
|
||||
t.Errorf("%s: RTP = %d bp, want 9800", ticket.ID, rtp)
|
||||
if rtp != 9900 {
|
||||
t.Errorf("%s: RTP = %d bp, want 9900", ticket.ID, rtp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ import (
|
||||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||
)
|
||||
|
||||
// HouseEdgeBP is the house edge in basis points (200 = 2.00%).
|
||||
const HouseEdgeBP int64 = 200
|
||||
// HouseEdgeBP is the house edge in basis points (100 = 1.00%).
|
||||
//
|
||||
// One percent is deliberately generous — better than almost anything
|
||||
// commercial. This is a game among friends, not a revenue stream, and a
|
||||
// thinner edge means the pot lasts the whole night instead of draining
|
||||
// toward the house.
|
||||
const HouseEdgeBP int64 = 100
|
||||
|
||||
// TickHz is the simulation rate. Rounds advance in whole ticks only.
|
||||
const TickHz = 60
|
||||
|
||||
@@ -41,8 +41,8 @@ func TestHouseEdgeAtTwoX(t *testing.T) {
|
||||
}
|
||||
}
|
||||
pct := float64(wins) * 100 / n
|
||||
if pct < 47.5 || pct > 50.5 {
|
||||
t.Fatalf("win rate at 2.00x = %.2f%%, want ~49%%", pct)
|
||||
if pct < 48.5 || pct > 51.0 {
|
||||
t.Fatalf("win rate at 2.00x = %.2f%%, want ~49.5%%", pct)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ func TestExpectedReturnMatchesEdge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
rtp := returned * 100 / n
|
||||
if rtp < 96.0 || rtp > 100.0 {
|
||||
t.Fatalf("RTP at %dx = %.2f%%, want ~98%%", targetX, rtp)
|
||||
if rtp < 97.0 || rtp > 101.0 {
|
||||
t.Fatalf("RTP at %dx = %.2f%%, want ~99%%", targetX, rtp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user