feat: playable arcade — rooms, identity, client, deployment
Round length is now bounded: the multiplier follows a hyperbolic curve diverging at 60s, replacing an exponential one where a 275x crash point produced a two-and-a-half minute round. Fixes seed reveal, which silently failed every round because pgx cannot encode a fixed-size byte array as bytea. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
116
pkg/identity/identity.go
Normal file
116
pkg/identity/identity.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package identity implements keypair-based sign-in.
|
||||
//
|
||||
// There are no accounts in the usual sense: a player's ed25519 public key is
|
||||
// their identity. To prove ownership they sign a server-issued challenge, which
|
||||
// is single-use and short-lived. There is no password to leak, no email to
|
||||
// verify, and nothing to reset — losing the key loses the balance, which is
|
||||
// stated plainly in the interface.
|
||||
package identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownChallenge = errors.New("identity: challenge not found or expired")
|
||||
ErrBadSignature = errors.New("identity: signature does not verify")
|
||||
ErrBadPublicKey = errors.New("identity: malformed public key")
|
||||
)
|
||||
|
||||
// ChallengeTTL is how long a challenge stays valid. Short, because a client
|
||||
// signs it immediately.
|
||||
const ChallengeTTL = 2 * time.Minute
|
||||
|
||||
type challenge struct {
|
||||
nonce [32]byte
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// Authenticator issues and verifies sign-in challenges.
|
||||
type Authenticator struct {
|
||||
mu sync.Mutex
|
||||
challenges map[string]challenge
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAuthenticator() *Authenticator {
|
||||
return &Authenticator{
|
||||
challenges: make(map[string]challenge),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Challenge issues a fresh nonce for a public key to sign.
|
||||
func (a *Authenticator) Challenge(pubkeyHex string) (string, error) {
|
||||
pk, err := ParsePublicKey(pubkeyHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var n [32]byte
|
||||
if _, err := rand.Read(n[:]); err != nil {
|
||||
panic("identity: system randomness unavailable: " + err.Error())
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.sweepLocked()
|
||||
a.challenges[hex.EncodeToString(pk)] = challenge{
|
||||
nonce: n,
|
||||
expires: a.now().Add(ChallengeTTL),
|
||||
}
|
||||
return hex.EncodeToString(n[:]), nil
|
||||
}
|
||||
|
||||
// Verify checks a signature over the outstanding challenge for that key and
|
||||
// consumes it, so a captured signature cannot be replayed.
|
||||
func (a *Authenticator) Verify(pubkeyHex, signatureHex string) error {
|
||||
pk, err := ParsePublicKey(pubkeyHex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sig, err := hex.DecodeString(signatureHex)
|
||||
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||
return ErrBadSignature
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
key := hex.EncodeToString(pk)
|
||||
c, ok := a.challenges[key]
|
||||
if ok {
|
||||
delete(a.challenges, key) // single use, whether or not it verifies
|
||||
}
|
||||
now := a.now()
|
||||
a.mu.Unlock()
|
||||
|
||||
if !ok || now.After(c.expires) {
|
||||
return ErrUnknownChallenge
|
||||
}
|
||||
if !ed25519.Verify(pk, c.nonce[:], sig) {
|
||||
return ErrBadSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sweepLocked drops expired challenges. Called under the mutex.
|
||||
func (a *Authenticator) sweepLocked() {
|
||||
now := a.now()
|
||||
for k, c := range a.challenges {
|
||||
if now.After(c.expires) {
|
||||
delete(a.challenges, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePublicKey decodes and validates a hex-encoded ed25519 public key.
|
||||
func ParsePublicKey(s string) (ed25519.PublicKey, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil || len(b) != ed25519.PublicKeySize {
|
||||
return nil, ErrBadPublicKey
|
||||
}
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
86
pkg/identity/identity_test.go
Normal file
86
pkg/identity/identity_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package identity_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/identity"
|
||||
)
|
||||
|
||||
func newKey(t *testing.T) (string, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hex.EncodeToString(pub), priv
|
||||
}
|
||||
|
||||
func TestValidSignatureAuthenticates(t *testing.T) {
|
||||
a := identity.NewAuthenticator()
|
||||
pubHex, priv := newKey(t)
|
||||
|
||||
nonceHex, err := a.Challenge(pubHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce, _ := hex.DecodeString(nonceHex)
|
||||
sig := ed25519.Sign(priv, nonce)
|
||||
|
||||
if err := a.Verify(pubHex, hex.EncodeToString(sig)); err != nil {
|
||||
t.Fatalf("valid signature rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeyCannotAuthenticate(t *testing.T) {
|
||||
a := identity.NewAuthenticator()
|
||||
pubHex, _ := newKey(t)
|
||||
_, otherPriv := newKey(t)
|
||||
|
||||
nonceHex, _ := a.Challenge(pubHex)
|
||||
nonce, _ := hex.DecodeString(nonceHex)
|
||||
sig := ed25519.Sign(otherPriv, nonce)
|
||||
|
||||
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrBadSignature) {
|
||||
t.Fatalf("got %v, want ErrBadSignature", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A captured signature must not work twice.
|
||||
func TestChallengeIsSingleUse(t *testing.T) {
|
||||
a := identity.NewAuthenticator()
|
||||
pubHex, priv := newKey(t)
|
||||
|
||||
nonceHex, _ := a.Challenge(pubHex)
|
||||
nonce, _ := hex.DecodeString(nonceHex)
|
||||
sigHex := hex.EncodeToString(ed25519.Sign(priv, nonce))
|
||||
|
||||
if err := a.Verify(pubHex, sigHex); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Verify(pubHex, sigHex); !errors.Is(err, identity.ErrUnknownChallenge) {
|
||||
t.Fatalf("replay succeeded or gave %v, want ErrUnknownChallenge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedKeyRejected(t *testing.T) {
|
||||
a := identity.NewAuthenticator()
|
||||
if _, err := a.Challenge("not-hex"); !errors.Is(err, identity.ErrBadPublicKey) {
|
||||
t.Fatalf("got %v, want ErrBadPublicKey", err)
|
||||
}
|
||||
if _, err := a.Challenge("aabb"); !errors.Is(err, identity.ErrBadPublicKey) {
|
||||
t.Fatalf("short key: got %v, want ErrBadPublicKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWithoutChallengeFails(t *testing.T) {
|
||||
a := identity.NewAuthenticator()
|
||||
pubHex, priv := newKey(t)
|
||||
sig := ed25519.Sign(priv, []byte("anything"))
|
||||
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrUnknownChallenge) {
|
||||
t.Fatalf("got %v, want ErrUnknownChallenge", err)
|
||||
}
|
||||
}
|
||||
446
pkg/room/room.go
Normal file
446
pkg/room/room.go
Normal file
@@ -0,0 +1,446 @@
|
||||
// Package room runs the shared crash rounds.
|
||||
//
|
||||
// A round moves through four states: betting_open, locked, running, settled.
|
||||
// The server seed is committed before betting opens and revealed only at
|
||||
// settlement, so no one — including the operator — can know the crash point
|
||||
// while bets are still being placed.
|
||||
//
|
||||
// All money movement goes through the ledger in a single transaction per
|
||||
// settlement, which is what keeps the books balanced under load.
|
||||
package room
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/drjones/quantum-arcade/pkg/sim"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// State is the phase of a round.
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateBetting State = "betting_open"
|
||||
StateLocked State = "locked"
|
||||
StateRunning State = "running"
|
||||
StateSettled State = "settled"
|
||||
)
|
||||
|
||||
// Timings. The betting window is deliberately generous: at a party, people are
|
||||
// walking up to their phones mid-round.
|
||||
const (
|
||||
BettingWindow = 20 * time.Second
|
||||
LockedPause = 3 * time.Second
|
||||
SettledPause = 7 * time.Second
|
||||
TickInterval = time.Second / sim.TickHz
|
||||
)
|
||||
|
||||
// Bet is one player's position in the current round.
|
||||
type Bet struct {
|
||||
AccountID int64
|
||||
Pubkey []byte
|
||||
Nickname string
|
||||
StakeMsat int64
|
||||
CashedOutAt fixed.F // zero until they cash out
|
||||
PayoutMsat int64
|
||||
}
|
||||
|
||||
// Snapshot is what clients render. It carries the seed inputs so a client can
|
||||
// verify the round the moment it settles.
|
||||
type Snapshot struct {
|
||||
RoundID int64 `json:"round_id"`
|
||||
Game string `json:"game"`
|
||||
State State `json:"state"`
|
||||
Tick int `json:"tick"`
|
||||
Multiplier string `json:"multiplier"`
|
||||
Commitment string `json:"commitment"`
|
||||
ServerSeed string `json:"server_seed,omitempty"` // only once settled
|
||||
CrashPoint string `json:"crash_point,omitempty"` // only once settled
|
||||
Players []Player `json:"players"`
|
||||
HousePotMsat int64 `json:"house_pot_msat"`
|
||||
NextPhaseIn float64 `json:"next_phase_in_seconds"`
|
||||
}
|
||||
|
||||
// Player is the public view of a participant.
|
||||
type Player struct {
|
||||
Nickname string `json:"nickname"`
|
||||
PubkeyHex string `json:"pubkey"`
|
||||
StakeMsat int64 `json:"stake_msat"`
|
||||
CashedOut string `json:"cashed_out,omitempty"`
|
||||
PayoutMsat int64 `json:"payout_msat"`
|
||||
}
|
||||
|
||||
// Room runs one game's round loop.
|
||||
type Room struct {
|
||||
Game string
|
||||
|
||||
pool *pgxpool.Pool
|
||||
ledger *ledger.Ledger
|
||||
|
||||
mu sync.RWMutex
|
||||
roundID int64
|
||||
state State
|
||||
tick int
|
||||
nonce uint64
|
||||
serverSeed fair.ServerSeed
|
||||
commitment [32]byte
|
||||
crashPoint fixed.F
|
||||
bets map[int64]*Bet
|
||||
order [][]byte // participant pubkeys in join order
|
||||
phaseEnds time.Time
|
||||
|
||||
subscribers map[chan Snapshot]struct{}
|
||||
subMu sync.Mutex
|
||||
}
|
||||
|
||||
func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
|
||||
return &Room{
|
||||
Game: game,
|
||||
pool: pool,
|
||||
ledger: l,
|
||||
state: StateSettled,
|
||||
bets: make(map[int64]*Bet),
|
||||
subscribers: make(map[chan Snapshot]struct{}),
|
||||
phaseEnds: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe returns a channel of snapshots. The channel is buffered and drops
|
||||
// updates rather than blocking the round loop: a slow phone must never stall
|
||||
// the game for everyone else.
|
||||
func (r *Room) Subscribe() (<-chan Snapshot, func()) {
|
||||
ch := make(chan Snapshot, 8)
|
||||
r.subMu.Lock()
|
||||
r.subscribers[ch] = struct{}{}
|
||||
r.subMu.Unlock()
|
||||
|
||||
return ch, func() {
|
||||
r.subMu.Lock()
|
||||
delete(r.subscribers, ch)
|
||||
close(ch)
|
||||
r.subMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Room) broadcast() {
|
||||
snap := r.Snapshot()
|
||||
r.subMu.Lock()
|
||||
defer r.subMu.Unlock()
|
||||
for ch := range r.subscribers {
|
||||
select {
|
||||
case ch <- snap:
|
||||
default: // subscriber is behind; skip this frame
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run drives the round loop until the context is cancelled.
|
||||
func (r *Room) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(TickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := r.step(ctx); err != nil {
|
||||
// A failed step must not kill the room; log-and-continue keeps
|
||||
// the arcade running even if the database blips.
|
||||
fmt.Printf("room %s: step error: %v\n", r.Game, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Room) step(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
state, phaseEnds := r.state, r.phaseEnds
|
||||
r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch state {
|
||||
case StateSettled:
|
||||
if now.After(phaseEnds) {
|
||||
return r.openRound(ctx)
|
||||
}
|
||||
case StateBetting:
|
||||
if now.After(phaseEnds) {
|
||||
r.mu.Lock()
|
||||
r.state = StateLocked
|
||||
r.phaseEnds = now.Add(LockedPause)
|
||||
r.mu.Unlock()
|
||||
r.broadcast()
|
||||
}
|
||||
case StateLocked:
|
||||
if now.After(phaseEnds) {
|
||||
return r.startRunning(ctx)
|
||||
}
|
||||
case StateRunning:
|
||||
r.mu.Lock()
|
||||
r.tick++
|
||||
reached := sim.MultiplierAt(r.tick)
|
||||
// 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
|
||||
r.mu.Unlock()
|
||||
|
||||
if crashed {
|
||||
return r.settle(ctx)
|
||||
}
|
||||
r.broadcast()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openRound commits to a fresh server seed and opens betting.
|
||||
func (r *Room) openRound(ctx context.Context) error {
|
||||
seed := fair.NewServerSeed()
|
||||
commitment := seed.Commitment()
|
||||
|
||||
r.mu.Lock()
|
||||
r.nonce++
|
||||
nonce := r.nonce
|
||||
r.mu.Unlock()
|
||||
|
||||
var roundID int64
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO rounds (game, nonce, commitment) VALUES ($1, $2, $3) RETURNING id`,
|
||||
r.Game, int64(nonce), commitment[:]).Scan(&roundID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening round: %w", err)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.roundID = roundID
|
||||
r.serverSeed = seed
|
||||
r.commitment = commitment
|
||||
r.crashPoint = 0
|
||||
r.tick = 0
|
||||
r.bets = make(map[int64]*Bet)
|
||||
r.order = nil
|
||||
r.state = StateBetting
|
||||
r.phaseEnds = time.Now().Add(BettingWindow)
|
||||
r.mu.Unlock()
|
||||
|
||||
r.broadcast()
|
||||
return nil
|
||||
}
|
||||
|
||||
// startRunning derives the crash point from the committed seed and the
|
||||
// participant set, then begins the climb.
|
||||
func (r *Room) startRunning(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
clientSeed := fair.ClientSeed(r.order)
|
||||
roundSeed := fair.RoundSeed(r.serverSeed, clientSeed, r.nonce)
|
||||
r.crashPoint = sim.CrashPoint(roundSeed)
|
||||
r.state = StateRunning
|
||||
r.tick = 0
|
||||
roundID := r.roundID
|
||||
crash := r.crashPoint
|
||||
r.mu.Unlock()
|
||||
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE rounds SET locked_at = now(), client_seed = $2, crash_point = $3
|
||||
WHERE id = $1`,
|
||||
roundID, clientSeed[:], int64(crash)); err != nil {
|
||||
return fmt.Errorf("locking round: %w", err)
|
||||
}
|
||||
|
||||
r.broadcast()
|
||||
return nil
|
||||
}
|
||||
|
||||
// settle pays out everyone who cashed out in time and reveals the seed.
|
||||
// Payouts are written as one ledger transaction so the books cannot be left
|
||||
// half-updated.
|
||||
func (r *Room) settle(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
roundID := r.roundID
|
||||
seed := r.serverSeed
|
||||
crash := r.crashPoint
|
||||
bets := make([]*Bet, 0, len(r.bets))
|
||||
for _, b := range r.bets {
|
||||
bets = append(bets, b)
|
||||
}
|
||||
r.state = StateSettled
|
||||
r.phaseEnds = time.Now().Add(SettledPause)
|
||||
r.mu.Unlock()
|
||||
|
||||
house, err := r.ledger.AccountByName(ctx, "house_pot")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var postings []ledger.Posting
|
||||
var housePays int64
|
||||
for _, b := range bets {
|
||||
if b.CashedOutAt == 0 {
|
||||
continue // rode it into the crash; the stake already sits with the house
|
||||
}
|
||||
payout := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
|
||||
b.PayoutMsat = payout
|
||||
if payout > 0 {
|
||||
postings = append(postings, ledger.Posting{AccountID: b.AccountID, AmountMsat: payout})
|
||||
housePays += payout
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE bets SET payout_msat = $2, settled_at = now()
|
||||
WHERE round_id = $1 AND account_id = $3`,
|
||||
roundID, payout, b.AccountID); err != nil {
|
||||
return fmt.Errorf("recording payout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if housePays > 0 {
|
||||
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -housePays})
|
||||
rid := roundID
|
||||
if _, err := r.ledger.Post(ctx, "payout", &rid, postings); err != nil {
|
||||
return fmt.Errorf("settling round %d: %w", roundID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pgx encodes byte slices, not fixed-size arrays, so the seed is sliced.
|
||||
seedBytes := seed.Bytes()
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE rounds SET settled_at = now(), server_seed = $2 WHERE id = $1`,
|
||||
roundID, seedBytes[:]); err != nil {
|
||||
return fmt.Errorf("revealing seed: %w", err)
|
||||
}
|
||||
|
||||
_ = crash
|
||||
r.broadcast()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if stakeMsat <= 0 {
|
||||
return ledger.ErrNonPositiveAmount
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if r.state != StateBetting {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("betting is closed")
|
||||
}
|
||||
if _, exists := r.bets[accountID]; exists {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("already in this round")
|
||||
}
|
||||
roundID := r.roundID
|
||||
r.mu.Unlock()
|
||||
|
||||
house, err := r.ledger.AccountByName(ctx, "house_pot")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rid := roundID
|
||||
if _, err := r.ledger.Post(ctx, "bet", &rid, []ledger.Posting{
|
||||
{AccountID: accountID, AmountMsat: -stakeMsat},
|
||||
{AccountID: house, AmountMsat: stakeMsat},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO bets (round_id, account_id, stake_msat) VALUES ($1, $2, $3)`,
|
||||
roundID, accountID, stakeMsat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
// Re-check state: the window may have closed while we were in the database.
|
||||
if r.state != StateBetting || r.roundID != roundID {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("betting closed while placing bet")
|
||||
}
|
||||
r.bets[accountID] = &Bet{
|
||||
AccountID: accountID, Pubkey: pubkey,
|
||||
Nickname: nickname, StakeMsat: stakeMsat,
|
||||
}
|
||||
r.order = append(r.order, pubkey)
|
||||
r.mu.Unlock()
|
||||
|
||||
r.broadcast()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CashOut locks in the current multiplier. It is rejected once the round has
|
||||
// passed the crash point, which the tick loop enforces by settling first.
|
||||
func (r *Room) CashOut(accountID int64) (fixed.F, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.state != StateRunning {
|
||||
return 0, fmt.Errorf("round is not running")
|
||||
}
|
||||
b, ok := r.bets[accountID]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no bet in this round")
|
||||
}
|
||||
if b.CashedOutAt != 0 {
|
||||
return 0, fmt.Errorf("already cashed out")
|
||||
}
|
||||
at := sim.MultiplierAt(r.tick)
|
||||
if at >= r.crashPoint {
|
||||
return 0, fmt.Errorf("too late")
|
||||
}
|
||||
b.CashedOutAt = at
|
||||
|
||||
go func() {
|
||||
if _, err := r.pool.Exec(context.Background(),
|
||||
`UPDATE bets SET cashout_at = $3 WHERE round_id = $1 AND account_id = $2`,
|
||||
r.roundID, accountID, int64(at)); err != nil {
|
||||
fmt.Printf("room %s: recording cashout: %v\n", r.Game, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return at, nil
|
||||
}
|
||||
|
||||
// Snapshot renders the current state for clients.
|
||||
func (r *Room) Snapshot() Snapshot {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
players := make([]Player, 0, len(r.bets))
|
||||
for _, b := range r.bets {
|
||||
p := Player{
|
||||
Nickname: b.Nickname,
|
||||
PubkeyHex: hex.EncodeToString(b.Pubkey),
|
||||
StakeMsat: b.StakeMsat,
|
||||
PayoutMsat: b.PayoutMsat,
|
||||
}
|
||||
if b.CashedOutAt != 0 {
|
||||
p.CashedOut = b.CashedOutAt.String()
|
||||
}
|
||||
players = append(players, p)
|
||||
}
|
||||
|
||||
s := Snapshot{
|
||||
RoundID: r.roundID,
|
||||
Game: r.Game,
|
||||
State: r.state,
|
||||
Tick: r.tick,
|
||||
Multiplier: sim.MultiplierAt(r.tick).String(),
|
||||
Commitment: hex.EncodeToString(r.commitment[:]),
|
||||
Players: players,
|
||||
NextPhaseIn: time.Until(r.phaseEnds).Seconds(),
|
||||
}
|
||||
// The seed is revealed only once the round is over.
|
||||
if r.state == StateSettled && r.crashPoint != 0 {
|
||||
s.ServerSeed = r.serverSeed.Hex()
|
||||
s.CrashPoint = r.crashPoint.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -12,10 +12,17 @@ const HouseEdgeBP int64 = 200
|
||||
// TickHz is the simulation rate. Rounds advance in whole ticks only.
|
||||
const TickHz = 60
|
||||
|
||||
// growthPerTickBP is multiplier growth per tick, in basis points of the current
|
||||
// value. At 6bp and 60Hz the multiplier reaches 2x in roughly 19 seconds, which
|
||||
// is long enough to feel the climb and short enough to keep rounds moving.
|
||||
const growthPerTickBP int64 = 6
|
||||
// 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.
|
||||
@@ -48,31 +55,49 @@ func CrashPoint(seed [32]byte) fixed.F {
|
||||
return cp
|
||||
}
|
||||
|
||||
// step is the per-tick growth factor, 1 + growthPerTickBP/10000, in Q32.32.
|
||||
func step() fixed.F {
|
||||
return fixed.One + fixed.F(growthPerTickBP<<32/10000)
|
||||
}
|
||||
|
||||
// MultiplierAt returns the multiplier displayed at a given tick of the round,
|
||||
// compounding from 1.0.
|
||||
// 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 {
|
||||
m := fixed.One
|
||||
s := step()
|
||||
for i := 0; i < tick; i++ {
|
||||
m = m.Mul(s)
|
||||
if tick <= 0 {
|
||||
return fixed.One
|
||||
}
|
||||
return m
|
||||
// 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.
|
||||
// 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 {
|
||||
cur := fixed.One
|
||||
s := step()
|
||||
for tick := 0; tick < 1_000_000; tick++ {
|
||||
if cur >= m {
|
||||
return tick
|
||||
}
|
||||
cur = cur.Mul(s)
|
||||
if m <= fixed.One {
|
||||
return 0
|
||||
}
|
||||
return 1_000_000
|
||||
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
|
||||
}
|
||||
|
||||
@@ -95,3 +95,33 @@ func TestTicksToMultiplierRoundTrips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No round may outlast the ceiling, however extreme the crash point.
|
||||
func TestRoundLengthIsBounded(t *testing.T) {
|
||||
if got := MultiplierAt(RoundTicks); got != MaxMultiplier() {
|
||||
t.Fatalf("curve past the ceiling = %v, want %v", got, MaxMultiplier())
|
||||
}
|
||||
// Even the most extreme crash point settles within the ceiling.
|
||||
worst := fixed.FromInt(4_000_000_000)
|
||||
if tick := TicksToMultiplier(worst); tick > RoundTicks {
|
||||
t.Fatalf("extreme crash point needs %d ticks, ceiling is %d", tick, RoundTicks)
|
||||
}
|
||||
}
|
||||
|
||||
// Timings that matter for how the game feels.
|
||||
func TestCurveTimings(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
multiplier int64
|
||||
maxSeconds float64
|
||||
}{
|
||||
{2, 20}, // the common case should arrive quickly
|
||||
{10, 45},
|
||||
{100, 56},
|
||||
} {
|
||||
tick := TicksToMultiplier(fixed.FromInt(c.multiplier))
|
||||
secs := float64(tick) / TickHz
|
||||
if secs > c.maxSeconds {
|
||||
t.Errorf("%dx takes %.1fs, want under %.0fs", c.multiplier, secs, c.maxSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user