Files
casino/pkg/sim/crash.go
drjones 48a9120fe4 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>
2026-08-05 16:24:31 +00:00

121 lines
4.1 KiB
Go

package sim
import (
"math/bits"
"github.com/drjones/quantum-arcade/pkg/fixed"
)
// 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
// RoundTicks is the hard ceiling on a round's length: 60 seconds at 60Hz.
//
// The multiplier follows a hyperbolic curve that diverges at exactly this
// tick, so no round can run longer no matter how extreme the crash point.
// An exponential curve has no such bound — a 275x round on one takes over two
// and a half minutes, which is unplayable when a dozen people are waiting.
const RoundTicks = 60 * TickHz
// MaxMultiplier is the largest value the curve expresses, reached on the final
// tick. Crash points at or above it settle when the round hits its ceiling.
func MaxMultiplier() fixed.F { return MultiplierAt(RoundTicks - 1) }
// CrashPoint derives the multiplier at which a round ends, as a pure function of
// the seed.
//
// The distribution is the inverse-uniform curve scaled by the house edge:
//
// crash = (1 - edge) / u, u uniform over (0, 1]
//
// which yields the same expected return of (1 - edge) at every cash-out target.
// No target is smarter than any other, so there is nothing to grind out.
func CrashPoint(seed [32]byte) fixed.F {
r := NewRNG(seed)
// u is uniform over [1, 2^32], giving a resolution of one part in 4 billion.
u := (r.Uint64() >> 32) + 1
// payoutRatio is (1 - edge) in Q32.32, e.g. 0.98.
payoutRatio := uint64((10000 - HouseEdgeBP) << 32 / 10000)
// crash = payoutRatio / (u / 2^32), computed as (payoutRatio * 2^32) / u
// through a 128-bit intermediate so no precision is lost.
hi, lo := bits.Mul64(payoutRatio, 1<<32)
q, _ := bits.Div64(hi, lo, u)
// The quotient can exceed int64 for the very smallest u — at u=1 it wraps
// negative, which would silently turn the rarest and most valuable outcome
// into an instant loss. Compare in unsigned space before converting.
//
// The cap is the largest multiplier the curve can express. Anything above
// it is unreachable anyway: the round would hit its tick ceiling first.
// It also bounds the maximum payout, so a single round cannot demand more
// than the house can hold.
maxCP := MaxMultiplier()
if q >= uint64(maxCP) {
return maxCP
}
cp := fixed.F(q)
if cp < fixed.One {
cp = fixed.One
}
return cp
}
// MultiplierAt returns the multiplier displayed at a given tick.
//
// m(t) = 1 / (1 - t/T)^2
//
// It starts at 1.0, rises slowly at first, and accelerates without bound as t
// approaches T. That acceleration is the tension: the longer you hold, the
// faster the number moves away from you, and the less time you have to react.
// It is also O(1), so a long round costs no more per tick than a short one.
func MultiplierAt(tick int) fixed.F {
if tick <= 0 {
return fixed.One
}
// Clamp the tick, not the value: clamping the value would make the curve
// step backwards at the boundary if rounding put the last computed point
// above the nominal ceiling.
if tick >= RoundTicks {
tick = RoundTicks - 1
}
// remaining = 1 - tick/T, always in (0, 1].
remaining := fixed.One - fixed.FromInt(int64(tick)).Div(fixed.FromInt(RoundTicks))
return fixed.One.Div(remaining.Mul(remaining))
}
// TicksToMultiplier returns the first tick at which MultiplierAt reaches m,
// inverting the curve: t = T * (1 - 1/sqrt(m)).
func TicksToMultiplier(m fixed.F) int {
if m <= fixed.One {
return 0
}
if m >= MaxMultiplier() {
return RoundTicks
}
inv := fixed.One.Div(fixed.Sqrt(m))
t := fixed.FromInt(RoundTicks).Mul(fixed.One - inv).Int()
// Rounding in fixed point can land a tick early; step forward to the first
// tick that genuinely reaches the target.
tick := int(t)
for tick > 0 && MultiplierAt(tick-1) >= m {
tick--
}
for tick < RoundTicks && MultiplierAt(tick) < m {
tick++
}
return tick
}