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:
280
cmd/arcade/e2e_test.go
Normal file
280
cmd/arcade/e2e_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
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:])
|
||||
}
|
||||
Reference in New Issue
Block a user