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>
345 lines
8.9 KiB
Go
345 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// These tests drive a running server. Start it with:
|
|
//
|
|
// ARCADE_DEV_FAUCET=1 go run ./cmd/arcade
|
|
//
|
|
// and run with ARCADE_E2E=http://localhost:8080. They are skipped otherwise so
|
|
// that `go test ./...` stays green without a live server.
|
|
func baseURL(t *testing.T) string {
|
|
t.Helper()
|
|
u := os.Getenv("ARCADE_E2E")
|
|
if u == "" {
|
|
t.Skip("set ARCADE_E2E to run end-to-end tests")
|
|
}
|
|
return u
|
|
}
|
|
|
|
type client struct {
|
|
t *testing.T
|
|
base string
|
|
token string
|
|
pub ed25519.PublicKey
|
|
priv ed25519.PrivateKey
|
|
}
|
|
|
|
func newClient(t *testing.T) *client {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &client{t: t, base: baseURL(t), pub: pub, priv: priv}
|
|
}
|
|
|
|
func (c *client) do(method, path string, body, out any) int {
|
|
c.t.Helper()
|
|
var buf io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
buf = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, c.base+path, buf)
|
|
if err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if out != nil {
|
|
_ = json.NewDecoder(res.Body).Decode(out)
|
|
}
|
|
return res.StatusCode
|
|
}
|
|
|
|
func (c *client) signIn(nickname string) {
|
|
c.t.Helper()
|
|
pubHex := hex.EncodeToString(c.pub)
|
|
|
|
var chal struct{ Challenge string }
|
|
if code := c.do("POST", "/api/auth/challenge",
|
|
map[string]string{"pubkey": pubHex}, &chal); code != 200 {
|
|
c.t.Fatalf("challenge failed: %d", code)
|
|
}
|
|
nonce, _ := hex.DecodeString(chal.Challenge)
|
|
sig := ed25519.Sign(c.priv, nonce)
|
|
|
|
var res struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if code := c.do("POST", "/api/auth/verify", map[string]string{
|
|
"pubkey": pubHex, "signature": hex.EncodeToString(sig), "nickname": nickname,
|
|
}, &res); code != 200 {
|
|
c.t.Fatalf("verify failed: %d", code)
|
|
}
|
|
c.token = res.Token
|
|
}
|
|
|
|
func (c *client) fund(msat int64) int64 {
|
|
c.t.Helper()
|
|
var res struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
if code := c.do("POST", "/api/dev/faucet",
|
|
map[string]int64{"amount_msat": msat}, &res); code != 200 {
|
|
c.t.Fatalf("faucet failed: %d (is ARCADE_DEV_FAUCET=1 set?)", code)
|
|
}
|
|
return res.BalanceMsat
|
|
}
|
|
|
|
func TestSignInAndFund(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("tester")
|
|
if bal := c.fund(50_000_000); bal < 50_000_000 {
|
|
t.Fatalf("balance after faucet = %d", bal)
|
|
}
|
|
}
|
|
|
|
func TestUnauthenticatedRequestsRejected(t *testing.T) {
|
|
c := newClient(t)
|
|
var out map[string]any
|
|
if code := c.do("GET", "/api/balance", nil, &out); code != 401 {
|
|
t.Fatalf("unauthenticated balance returned %d, want 401", code)
|
|
}
|
|
if code := c.do("POST", "/api/bet",
|
|
map[string]any{"game": "rocket", "stake_msat": 1000}, &out); code != 401 {
|
|
t.Fatalf("unauthenticated bet returned %d, want 401", code)
|
|
}
|
|
}
|
|
|
|
func TestCannotBetMoreThanBalance(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("broke")
|
|
// No faucet call: balance is zero.
|
|
var out map[string]any
|
|
code := c.do("POST", "/api/bet",
|
|
map[string]any{"game": "rocket", "stake_msat": 1_000_000}, &out)
|
|
if code != 400 {
|
|
t.Fatalf("betting without funds returned %d, want 400", code)
|
|
}
|
|
}
|
|
|
|
// Play a full round: wait for a betting window, bet, and confirm the stake left
|
|
// the balance and the round eventually settles and reveals its seed.
|
|
func TestFullRoundLifecycleAndVerification(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("player")
|
|
c.fund(50_000_000)
|
|
|
|
const stake = 1_000_000
|
|
var roundID int64
|
|
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
var games struct {
|
|
Rooms []struct {
|
|
RoundID int64 `json:"round_id"`
|
|
Game string `json:"game"`
|
|
State string `json:"state"`
|
|
} `json:"rooms"`
|
|
}
|
|
c.do("GET", "/api/games", nil, &games)
|
|
|
|
for _, rm := range games.Rooms {
|
|
if rm.Game != "rocket" || rm.State != "betting_open" {
|
|
continue
|
|
}
|
|
var res struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
Error string `json:"error"`
|
|
}
|
|
if code := c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": stake, "nickname": "player",
|
|
}, &res); code == 200 {
|
|
roundID = rm.RoundID
|
|
}
|
|
}
|
|
if roundID != 0 {
|
|
break
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
if roundID == 0 {
|
|
t.Fatal("never managed to place a bet within 90s")
|
|
}
|
|
|
|
// Wait for the round to settle and expose its proof.
|
|
var proof struct {
|
|
Commitment string `json:"commitment"`
|
|
ServerSeed string `json:"server_seed"`
|
|
ClientSeed string `json:"client_seed"`
|
|
Nonce int64 `json:"nonce"`
|
|
Participants []string `json:"participants"`
|
|
}
|
|
settled := false
|
|
deadline = time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if code := c.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
|
|
settled = true
|
|
break
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
if !settled {
|
|
t.Fatal("round never settled")
|
|
}
|
|
|
|
// The revealed seed must match the commitment published before betting.
|
|
seed, err := hex.DecodeString(proof.ServerSeed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256(seed)
|
|
if hex.EncodeToString(sum[:]) != proof.Commitment {
|
|
t.Fatalf("commitment mismatch:\n published %s\n actual %s",
|
|
proof.Commitment, hex.EncodeToString(sum[:]))
|
|
}
|
|
if len(proof.Participants) == 0 {
|
|
t.Fatal("settled round lists no participants")
|
|
}
|
|
}
|
|
|
|
// The books must balance at all times, which the health endpoint reports.
|
|
func TestLedgerStaysBalanced(t *testing.T) {
|
|
c := newClient(t)
|
|
var health struct {
|
|
Status string `json:"status"`
|
|
LedgerSumMsat int64 `json:"ledger_sum_msat"`
|
|
}
|
|
if code := c.do("GET", "/api/health", nil, &health); code != 200 {
|
|
t.Fatalf("health returned %d", code)
|
|
}
|
|
if health.LedgerSumMsat != 0 {
|
|
t.Fatalf("ledger does not balance: sum = %d", health.LedgerSumMsat)
|
|
}
|
|
if health.Status != "ok" {
|
|
t.Fatalf("health status = %q", health.Status)
|
|
}
|
|
}
|
|
|
|
func TestScratchTicketPlaysAndPays(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("scratcher")
|
|
start := c.fund(100_000_000)
|
|
|
|
var res struct {
|
|
Outcome struct {
|
|
TierName string `json:"tier_name"`
|
|
PayoutMsat int64 `json:"payout_msat"`
|
|
Cells []int `json:"cells"`
|
|
} `json:"outcome"`
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
const stake = 1_000_000
|
|
if code := c.do("POST", "/api/scratch/play",
|
|
map[string]any{"ticket_id": "nebula-nine", "stake_msat": stake}, &res); code != 200 {
|
|
t.Fatalf("scratch play returned %d", code)
|
|
}
|
|
if len(res.Outcome.Cells) != 9 {
|
|
t.Fatalf("got %d cells, want 9", len(res.Outcome.Cells))
|
|
}
|
|
want := start - stake + res.Outcome.PayoutMsat
|
|
if res.BalanceMsat != want {
|
|
t.Fatalf("balance = %d, want %d (start %d, stake %d, payout %d)",
|
|
res.BalanceMsat, want, start, stake, res.Outcome.PayoutMsat)
|
|
}
|
|
}
|
|
|
|
func itoa(v int64) string {
|
|
if v == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for v > 0 {
|
|
i--
|
|
buf[i] = byte('0' + v%10)
|
|
v /= 10
|
|
}
|
|
return string(buf[i:])
|
|
}
|
|
|
|
// An auto cash-out target must be accepted by the API and reflected in the
|
|
// round, and an invalid one must be refused before any money moves.
|
|
func TestAutoCashOutThroughTheAPI(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("autoplayer")
|
|
start := c.fund(50_000_000)
|
|
|
|
var out map[string]any
|
|
// A target at or below 1.00 is meaningless and must be rejected.
|
|
code := c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1.0,
|
|
}, &out)
|
|
if code == 200 {
|
|
t.Fatal("a 1.00x auto cash-out target was accepted")
|
|
}
|
|
|
|
// An absurd target must be refused rather than overflowing the conversion.
|
|
code = c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1e12,
|
|
}, &out)
|
|
if code == 200 {
|
|
t.Fatal("an absurd auto cash-out target was accepted")
|
|
}
|
|
|
|
// Neither rejection may have moved money.
|
|
var bal struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
c.do("GET", "/api/balance", nil, &bal)
|
|
if bal.BalanceMsat != start {
|
|
t.Fatalf("balance = %d after rejected bets, want %d", bal.BalanceMsat, start)
|
|
}
|
|
|
|
// A sensible target should be accepted during a betting window.
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
placed := false
|
|
for time.Now().Before(deadline) && !placed {
|
|
var games struct {
|
|
Rooms []struct {
|
|
Game string `json:"game"`
|
|
State string `json:"state"`
|
|
} `json:"rooms"`
|
|
}
|
|
c.do("GET", "/api/games", nil, &games)
|
|
for _, rm := range games.Rooms {
|
|
if rm.Game == "rocket" && rm.State == "betting_open" {
|
|
var res map[string]any
|
|
if code := c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": 1_000_000,
|
|
"auto_cashout": 2.5, "nickname": "autoplayer",
|
|
}, &res); code == 200 {
|
|
placed = true
|
|
}
|
|
}
|
|
}
|
|
if !placed {
|
|
time.Sleep(400 * time.Millisecond)
|
|
}
|
|
}
|
|
if !placed {
|
|
t.Fatal("could not place an auto cash-out bet within 90s")
|
|
}
|
|
}
|