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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
bin/
|
||||||
|
*.log
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Build the server as a static binary with the client embedded in it, then ship
|
||||||
|
# it on a minimal base. The result is one file with no runtime dependencies.
|
||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/arcade ./cmd/arcade
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=build /out/arcade /arcade
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nonroot:nonroot
|
||||||
|
ENTRYPOINT ["/arcade"]
|
||||||
126
README.md
Normal file
126
README.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Quantum Arcade
|
||||||
|
|
||||||
|
A private physics arcade for one Linux box and the people on your network.
|
||||||
|
Drop-in crash rounds, instant scratch tickets, a real double-entry ledger, and
|
||||||
|
outcomes any player can verify on their own phone.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
Three shared crash games — a rocket fighting gravity, a decaying orbit, and a
|
||||||
|
stacking tower — running on a five-minute heartbeat, plus instant scratch
|
||||||
|
tickets to play between rounds. Identity is a keypair your browser generates;
|
||||||
|
there is no account, no email, and no password.
|
||||||
|
|
||||||
|
Everything runs on one machine: one Go binary with the client embedded,
|
||||||
|
PostgreSQL, and Redis.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<your-box>:8080` from any phone on the network.
|
||||||
|
|
||||||
|
To play with test funds before Lightning is wired up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCADE_DEV_FAUCET=1 docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The faucet mints through the same ledger path a real deposit uses, so the code
|
||||||
|
under test is the production code. Leave it off otherwise.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/arcade
|
||||||
|
```
|
||||||
|
|
||||||
|
End-to-end tests need a live server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCADE_DEV_FAUCET=1 go run ./cmd/arcade &
|
||||||
|
ARCADE_E2E=http://localhost:8080 go test ./cmd/arcade/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## How fairness works
|
||||||
|
|
||||||
|
Before betting opens, the server generates a random seed and publishes
|
||||||
|
`SHA-256(seed)`. It is now committed and cannot change its mind.
|
||||||
|
|
||||||
|
The client seed is built from the public keys of everyone who joined the round.
|
||||||
|
The operator does not choose who plays, so it cannot steer the outcome even
|
||||||
|
knowing its own seed.
|
||||||
|
|
||||||
|
The crash point is `HMAC-SHA256(serverSeed, clientSeed || nonce)`, run through
|
||||||
|
the simulation. After settlement the seed is published, and the Verify tab
|
||||||
|
recomputes the whole chain in your browser — it asks the server only for the
|
||||||
|
published values, never for a verdict.
|
||||||
|
|
||||||
|
Scratch tickets use the identical pipeline, and their odds tables are generated
|
||||||
|
from the same data structure that produces outcomes, so the published odds
|
||||||
|
cannot drift from reality. A test asserts observed frequencies and empirical
|
||||||
|
return against the published figures across two million plays.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
One binary, with enforced internal boundaries:
|
||||||
|
|
||||||
|
| Package | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `pkg/fixed` | Q32.32 fixed-point arithmetic; no floats, so results are identical everywhere |
|
||||||
|
| `pkg/sim` | Deterministic RNG and the crash curve |
|
||||||
|
| `pkg/fair` | Commit-reveal protocol and verification proofs |
|
||||||
|
| `pkg/ledger` | Append-only double-entry accounting |
|
||||||
|
| `pkg/scratch` | Scratch tickets and their published odds |
|
||||||
|
| `pkg/identity` | Keypair sign-in via signed challenge |
|
||||||
|
| `pkg/room` | Round lifecycle and live broadcast |
|
||||||
|
|
||||||
|
Nine services on one machine would buy latency and 3am debugging, so this is
|
||||||
|
one process. Modules talk through interfaces only; extracting one into its own
|
||||||
|
service later is a transport change, not a rewrite.
|
||||||
|
|
||||||
|
### Ledger invariants
|
||||||
|
|
||||||
|
Enforced in the application and again by database constraints and triggers:
|
||||||
|
|
||||||
|
- every transaction's postings sum to exactly zero
|
||||||
|
- no account may go negative, except the Lightning bridge, whose negative
|
||||||
|
balance is by definition what is owed to players
|
||||||
|
- rows are never updated or deleted; corrections are compensating entries
|
||||||
|
|
||||||
|
`GET /api/health` sums every account. It must return zero. Anything else means
|
||||||
|
the books are corrupt.
|
||||||
|
|
||||||
|
### Round timing
|
||||||
|
|
||||||
|
The multiplier follows `m(t) = 1/(1 - t/T)²`, which diverges at exactly 60
|
||||||
|
seconds. No round can run longer, however extreme the crash point, and the
|
||||||
|
climb visibly accelerates as it goes — which is where the tension comes from.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built and tested:
|
||||||
|
|
||||||
|
- fixed-point deterministic core, ledger, commit-reveal fairness
|
||||||
|
- three crash games with live multiplayer rounds
|
||||||
|
- two scratch tickets with verified-honest odds
|
||||||
|
- keypair identity, peer-to-peer transfers, transaction history
|
||||||
|
- in-browser verifier
|
||||||
|
|
||||||
|
Not yet built:
|
||||||
|
|
||||||
|
- **Lightning deposits and withdrawals.** The bridge account and ledger paths
|
||||||
|
exist; the node integration does not. The dev faucet stands in for now.
|
||||||
|
- Tournaments and scheduled events
|
||||||
|
- Operator dashboard
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This is built to run on a private network among people who know each other.
|
||||||
|
It is not hardened for, and should not be exposed to, the public internet.
|
||||||
|
Doing so would make it a public real-money gambling service, which carries
|
||||||
|
licensing, KYC, and AML obligations this codebase does not address.
|
||||||
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:])
|
||||||
|
}
|
||||||
629
cmd/arcade/main.go
Normal file
629
cmd/arcade/main.go
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
// Command arcade is the Quantum Arcade server: one binary serving the API, the
|
||||||
|
// WebSocket round feed, and the static client.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/identity"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/room"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/scratch"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
// Games offered as shared rounds. They share one engine and differ in how the
|
||||||
|
// client renders the climb.
|
||||||
|
var games = []string{"rocket", "orbital", "tower"}
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
auth *identity.Authenticator
|
||||||
|
rooms map[string]*room.Room
|
||||||
|
|
||||||
|
// sessions maps a bearer token to a verified public key. Sessions live in
|
||||||
|
// memory only: restarting the server signs everyone out, which is fine for
|
||||||
|
// a machine you own and means there is no session store to leak.
|
||||||
|
sessMu sync.RWMutex
|
||||||
|
sessions map[string]string
|
||||||
|
|
||||||
|
// scratchNonce advances per play so each ticket has a distinct seed.
|
||||||
|
nonceMu sync.Mutex
|
||||||
|
scratchNonce uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("ARCADE_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
||||||
|
}
|
||||||
|
addr := os.Getenv("ARCADE_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("connecting to database: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
log.Fatalf("database unreachable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every ticket in the catalog must have coherent odds before we serve it.
|
||||||
|
for _, t := range scratch.Catalog {
|
||||||
|
if err := t.Validate(); err != nil {
|
||||||
|
log.Fatalf("scratch catalog: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &server{
|
||||||
|
pool: pool,
|
||||||
|
ledger: ledger.New(pool),
|
||||||
|
auth: identity.NewAuthenticator(),
|
||||||
|
rooms: make(map[string]*room.Room),
|
||||||
|
sessions: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range games {
|
||||||
|
r := room.New(g, pool, s.ledger)
|
||||||
|
s.rooms[g] = r
|
||||||
|
go func(r *room.Room) {
|
||||||
|
if err := r.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.Printf("room %s stopped: %v", r.Game, err)
|
||||||
|
}
|
||||||
|
}(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: s.routes(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("Quantum Arcade listening on %s", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("shutting down")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||||
|
mux.HandleFunc("POST /api/auth/challenge", s.handleChallenge)
|
||||||
|
mux.HandleFunc("POST /api/auth/verify", s.handleVerify)
|
||||||
|
mux.HandleFunc("GET /api/balance", s.handleBalance)
|
||||||
|
mux.HandleFunc("GET /api/history", s.handleHistory)
|
||||||
|
mux.HandleFunc("POST /api/transfer", s.handleTransfer)
|
||||||
|
mux.HandleFunc("GET /api/games", s.handleGames)
|
||||||
|
mux.HandleFunc("POST /api/bet", s.handleBet)
|
||||||
|
mux.HandleFunc("POST /api/cashout", s.handleCashout)
|
||||||
|
mux.HandleFunc("GET /api/scratch/catalog", s.handleScratchCatalog)
|
||||||
|
mux.HandleFunc("POST /api/scratch/play", s.handleScratchPlay)
|
||||||
|
mux.HandleFunc("GET /api/verify/{roundID}", s.handleVerifyRound)
|
||||||
|
|
||||||
|
// The faucet exists so the arcade is playable before Lightning is wired
|
||||||
|
// up. It mints from the bridge account exactly as a real deposit would,
|
||||||
|
// so the ledger path under test is the production one. Off by default.
|
||||||
|
if os.Getenv("ARCADE_DEV_FAUCET") == "1" {
|
||||||
|
log.Println("dev faucet ENABLED — funds are not backed by Lightning")
|
||||||
|
mux.HandleFunc("POST /api/dev/faucet", s.handleFaucet)
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /ws/{game}", s.handleWS)
|
||||||
|
|
||||||
|
sub, err := fs.Sub(staticFiles, "static")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("static assets: %v", err)
|
||||||
|
}
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(sub)))
|
||||||
|
|
||||||
|
return logRequests(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logRequests(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
if r.URL.Path != "/api/health" {
|
||||||
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// session resolves the caller's public key from the Authorization header.
|
||||||
|
func (s *server) session(r *http.Request) (string, bool) {
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
if len(token) > 7 && token[:7] == "Bearer " {
|
||||||
|
token = token[7:]
|
||||||
|
}
|
||||||
|
s.sessMu.RLock()
|
||||||
|
defer s.sessMu.RUnlock()
|
||||||
|
pk, ok := s.sessions[token]
|
||||||
|
return pk, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// account resolves the caller to a ledger account id.
|
||||||
|
func (s *server) account(r *http.Request) (int64, []byte, bool) {
|
||||||
|
pkHex, ok := s.session(r)
|
||||||
|
if !ok {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
pk, err := hex.DecodeString(pkHex)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
id, err := s.ledger.EnsurePlayer(r.Context(), pk)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
return id, pk, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- handlers ---
|
||||||
|
|
||||||
|
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
total, err := s.ledger.ConservationCheck(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A non-zero total means the books do not balance, which is a hard fault.
|
||||||
|
status := "ok"
|
||||||
|
if total != 0 {
|
||||||
|
status = "ledger_imbalance"
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"ledger_sum_msat": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Pubkey string `json:"pubkey"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nonce, err := s.auth.Challenge(req.Pubkey)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"challenge": nonce})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Pubkey string `json:"pubkey"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.auth.Verify(req.Pubkey, req.Signature); err != nil {
|
||||||
|
writeErr(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pk, _ := hex.DecodeString(req.Pubkey)
|
||||||
|
accountID, err := s.ledger.EnsurePlayer(r.Context(), pk)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Nickname != "" {
|
||||||
|
if _, err := s.pool.Exec(r.Context(),
|
||||||
|
`UPDATE accounts SET nickname = $2 WHERE id = $1`, accountID, req.Nickname); err != nil {
|
||||||
|
log.Printf("setting nickname: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenBytes [32]byte
|
||||||
|
seed := fair.NewServerSeed() // reuse the CSPRNG wrapper for session tokens
|
||||||
|
tokenBytes = seed.Bytes()
|
||||||
|
token := hex.EncodeToString(tokenBytes[:])
|
||||||
|
|
||||||
|
s.sessMu.Lock()
|
||||||
|
s.sessions[token] = req.Pubkey
|
||||||
|
s.sessMu.Unlock()
|
||||||
|
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), accountID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"balance_msat": bal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleBalance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bal, err := s.ledger.Balance(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := s.ledger.History(r.Context(), id, 50)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
from, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
ToPubkey string `json:"to_pubkey"`
|
||||||
|
AmountMsat int64 `json:"amount_msat"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to, err := hex.DecodeString(req.ToPubkey)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad recipient key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toID, err := s.ledger.EnsurePlayer(r.Context(), to)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ledger.Transfer(r.Context(), from, toID, req.AmountMsat); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), from)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleGames(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out := make([]room.Snapshot, 0, len(s.rooms))
|
||||||
|
for _, g := range games {
|
||||||
|
out = append(out, s.rooms[g].Snapshot())
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"rooms": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, pk, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Game string `json:"game"`
|
||||||
|
StakeMsat int64 `json:"stake_msat"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rm, ok := s.rooms[req.Game]
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusNotFound, "no such game")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleCashout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Game string `json:"game"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rm, ok := s.rooms[req.Game]
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusNotFound, "no such game")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
at, err := rm.CashOut(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"cashed_out_at": at.String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleScratchCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type entry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
Cells int `json:"cells"`
|
||||||
|
RTPBP uint64 `json:"rtp_bp"`
|
||||||
|
Odds []scratch.OddsRow `json:"odds"`
|
||||||
|
}
|
||||||
|
out := make([]entry, 0, len(scratch.Catalog))
|
||||||
|
for _, t := range scratch.Catalog {
|
||||||
|
out = append(out, entry{
|
||||||
|
ID: t.ID, Name: t.Name, Blurb: t.Blurb, Cells: t.Cells,
|
||||||
|
RTPBP: t.RTPBasisPoints(), Odds: t.Odds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tickets": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, pk, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
TicketID string `json:"ticket_id"`
|
||||||
|
StakeMsat int64 `json:"stake_msat"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticket, ok := scratch.ByID(req.TicketID)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusNotFound, "no such ticket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.StakeMsat <= 0 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "stake must be positive")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
house, err := s.ledger.AccountByName(r.Context(), "house_pot")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Take the stake first: if the player cannot cover it, nothing else happens.
|
||||||
|
if _, err := s.ledger.Post(r.Context(), "scratch_stake", nil, []ledger.Posting{
|
||||||
|
{AccountID: id, AmountMsat: -req.StakeMsat},
|
||||||
|
{AccountID: house, AmountMsat: req.StakeMsat},
|
||||||
|
}); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.nonceMu.Lock()
|
||||||
|
s.scratchNonce++
|
||||||
|
nonce := s.scratchNonce
|
||||||
|
s.nonceMu.Unlock()
|
||||||
|
|
||||||
|
server := fair.NewServerSeed()
|
||||||
|
outcome, proof := scratch.PlayFromRound(ticket, server, pk, nonce, req.StakeMsat)
|
||||||
|
|
||||||
|
if outcome.PayoutMsat > 0 {
|
||||||
|
if _, err := s.ledger.Post(r.Context(), "scratch_payout", nil, []ledger.Posting{
|
||||||
|
{AccountID: house, AmountMsat: -outcome.PayoutMsat},
|
||||||
|
{AccountID: id, AmountMsat: outcome.PayoutMsat},
|
||||||
|
}); err != nil {
|
||||||
|
// The house cannot cover the prize. Record it and surface it
|
||||||
|
// rather than silently voiding a winning ticket.
|
||||||
|
log.Printf("scratch payout failed for account %d: %v", id, err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "house cannot cover this prize; stake refunded")
|
||||||
|
_, _ = s.ledger.Post(r.Context(), "scratch_refund", nil, []ledger.Posting{
|
||||||
|
{AccountID: house, AmountMsat: -req.StakeMsat},
|
||||||
|
{AccountID: id, AmountMsat: req.StakeMsat},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commitment := server.Commitment()
|
||||||
|
seedBytes := server.Bytes()
|
||||||
|
if _, err := s.pool.Exec(r.Context(),
|
||||||
|
`INSERT INTO scratch_plays
|
||||||
|
(account_id, ticket_id, nonce, commitment, server_seed,
|
||||||
|
stake_msat, tier_name, payout_msat, cells)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||||
|
id, ticket.ID, int64(nonce), commitment[:], seedBytes[:],
|
||||||
|
req.StakeMsat, outcome.TierName, outcome.PayoutMsat, outcome.Cells); err != nil {
|
||||||
|
log.Printf("recording scratch play: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"outcome": outcome,
|
||||||
|
"proof": proof,
|
||||||
|
"balance_msat": bal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFaucet credits the caller from the bridge account. Development only.
|
||||||
|
func (s *server) handleFaucet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
AmountMsat int64 `json:"amount_msat"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountMsat <= 0 {
|
||||||
|
req.AmountMsat = 100_000_000 // 100k sats
|
||||||
|
}
|
||||||
|
if _, err := s.ledger.Deposit(r.Context(), id, req.AmountMsat); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVerifyRound returns everything needed to check a settled round.
|
||||||
|
func (s *server) handleVerifyRound(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("roundID")
|
||||||
|
roundID, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad round id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var game string
|
||||||
|
var nonce int64
|
||||||
|
var commitment, serverSeed, clientSeed []byte
|
||||||
|
var crashPoint *int64
|
||||||
|
err = s.pool.QueryRow(r.Context(),
|
||||||
|
`SELECT game, nonce, commitment, server_seed, client_seed, crash_point
|
||||||
|
FROM rounds WHERE id = $1`, roundID).
|
||||||
|
Scan(&game, &nonce, &commitment, &serverSeed, &clientSeed, &crashPoint)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "round not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if serverSeed == nil {
|
||||||
|
writeErr(w, http.StatusConflict, "round has not settled yet; seed is still sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(r.Context(),
|
||||||
|
`SELECT a.pubkey FROM bets b JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.round_id = $1 ORDER BY b.id`, roundID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var participants []string
|
||||||
|
for rows.Next() {
|
||||||
|
var pk []byte
|
||||||
|
if err := rows.Scan(&pk); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
participants = append(participants, hex.EncodeToString(pk))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"round_id": roundID,
|
||||||
|
"game": game,
|
||||||
|
"nonce": nonce,
|
||||||
|
"commitment": hex.EncodeToString(commitment),
|
||||||
|
"server_seed": hex.EncodeToString(serverSeed),
|
||||||
|
"client_seed": hex.EncodeToString(clientSeed),
|
||||||
|
"crash_point": crashPoint,
|
||||||
|
"participants": participants,
|
||||||
|
"how_to_verify": "sha256(server_seed) must equal commitment; " +
|
||||||
|
"client_seed is sha256 over each participant pubkey length-prefixed in join order; " +
|
||||||
|
"round seed is hmac-sha256(server_seed, client_seed || big-endian nonce)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWS streams round snapshots to a client.
|
||||||
|
func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
game := r.PathValue("game")
|
||||||
|
rm, ok := s.rooms[game]
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusNotFound, "no such game")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server is LAN-only, so any origin on the local network is acceptable.
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.CloseNow()
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
updates, unsubscribe := rm.Subscribe()
|
||||||
|
defer unsubscribe()
|
||||||
|
|
||||||
|
// Send the current state immediately so a joining phone is never blank.
|
||||||
|
if err := wsjson.Write(ctx, conn, rm.Snapshot()); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case snap, ok := <-updates:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := wsjson.Write(ctx, conn, snap); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
636
cmd/arcade/static/app.js
Normal file
636
cmd/arcade/static/app.js
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
/* Quantum Arcade client.
|
||||||
|
*
|
||||||
|
* Identity is an ed25519 keypair generated in the browser and kept in
|
||||||
|
* localStorage. There is no account to create and no password to lose.
|
||||||
|
*
|
||||||
|
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
|
||||||
|
* the server whether a round was fair — it checks.
|
||||||
|
*
|
||||||
|
* All dynamic content is inserted with textContent or built as DOM nodes.
|
||||||
|
* Nothing that originates from another player (nicknames, keys) or from the
|
||||||
|
* server ever reaches innerHTML. */
|
||||||
|
|
||||||
|
const KEY_STORAGE = 'quantum-arcade-key';
|
||||||
|
const NAME_STORAGE = 'quantum-arcade-name';
|
||||||
|
|
||||||
|
let keypair = null; // { publicKeyHex, privateKey (CryptoKey) }
|
||||||
|
let token = null;
|
||||||
|
let nickname = '';
|
||||||
|
let stake = 5000; // millisatoshis
|
||||||
|
let currentGame = 'rocket';
|
||||||
|
let socket = null;
|
||||||
|
let snapshot = null;
|
||||||
|
let myBet = null; // 'in' | 'out' | null
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
|
||||||
|
|
||||||
|
/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) {
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'text') node.textContent = v;
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
if (c == null) continue;
|
||||||
|
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(node) {
|
||||||
|
while (node.firstChild) node.removeChild(node.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- identity ---------------- */
|
||||||
|
|
||||||
|
async function loadOrCreateKey() {
|
||||||
|
const stored = localStorage.getItem(KEY_STORAGE);
|
||||||
|
if (stored) {
|
||||||
|
const jwk = JSON.parse(stored);
|
||||||
|
const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']);
|
||||||
|
return { privateKey: priv, publicKeyHex: jwk.qa_pub };
|
||||||
|
}
|
||||||
|
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||||
|
const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
|
||||||
|
const pubHex = hex(rawPub);
|
||||||
|
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||||||
|
jwk.qa_pub = pubHex;
|
||||||
|
localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk));
|
||||||
|
return { privateKey: kp.privateKey, publicKeyHex: pubHex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hex(bytes) {
|
||||||
|
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
function unhex(s) {
|
||||||
|
const out = new Uint8Array(s.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
nickname = ($('nickname').value || 'anon').trim().slice(0, 20);
|
||||||
|
localStorage.setItem(NAME_STORAGE, nickname);
|
||||||
|
|
||||||
|
const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex });
|
||||||
|
const sig = new Uint8Array(await crypto.subtle.sign(
|
||||||
|
{ name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge)));
|
||||||
|
|
||||||
|
const res = await api('POST', '/api/auth/verify', {
|
||||||
|
pubkey: keypair.publicKeyHex,
|
||||||
|
signature: hex(sig),
|
||||||
|
nickname,
|
||||||
|
});
|
||||||
|
token = res.token;
|
||||||
|
|
||||||
|
$('signin').hidden = true;
|
||||||
|
$('app').hidden = false;
|
||||||
|
$('balance-wrap').hidden = false;
|
||||||
|
setBalance(res.balance_msat);
|
||||||
|
$('pubkey').textContent = keypair.publicKeyHex;
|
||||||
|
|
||||||
|
connect(currentGame);
|
||||||
|
loadScratch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- api ---------------- */
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBalance(msat) {
|
||||||
|
$('balance').textContent = sats(msat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- crash room ---------------- */
|
||||||
|
|
||||||
|
function connect(game) {
|
||||||
|
if (socket) socket.close();
|
||||||
|
currentGame = game;
|
||||||
|
myBet = null;
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
|
||||||
|
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
|
||||||
|
socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSnapshot(s) {
|
||||||
|
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
|
||||||
|
snapshot = s;
|
||||||
|
if (roundChanged) myBet = null;
|
||||||
|
|
||||||
|
const mult = $('multiplier');
|
||||||
|
mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×';
|
||||||
|
mult.className = 'multiplier';
|
||||||
|
|
||||||
|
$('commitment').textContent = s.commitment || '—';
|
||||||
|
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
|
||||||
|
|
||||||
|
const action = $('action');
|
||||||
|
const hint = $('hint');
|
||||||
|
|
||||||
|
switch (s.state) {
|
||||||
|
case 'betting_open':
|
||||||
|
$('state').textContent = `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||||
|
action.textContent = myBet ? 'You are in — good luck' : 'Place bet';
|
||||||
|
action.className = 'primary big';
|
||||||
|
action.disabled = !!myBet;
|
||||||
|
break;
|
||||||
|
case 'locked':
|
||||||
|
$('state').textContent = 'launching';
|
||||||
|
action.textContent = 'Launching…';
|
||||||
|
action.disabled = true;
|
||||||
|
break;
|
||||||
|
case 'running':
|
||||||
|
$('state').textContent = 'in flight';
|
||||||
|
if (myBet === 'in') {
|
||||||
|
const payout = stake * parseFloat(s.multiplier);
|
||||||
|
action.textContent = `Cash out ${sats(payout)}`;
|
||||||
|
action.className = 'primary big cashout';
|
||||||
|
action.disabled = false;
|
||||||
|
} else {
|
||||||
|
action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching';
|
||||||
|
action.className = 'primary big';
|
||||||
|
action.disabled = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'settled':
|
||||||
|
if (s.crash_point) mult.textContent = parseFloat(s.crash_point).toFixed(2) + '×';
|
||||||
|
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||||||
|
$('state').textContent =
|
||||||
|
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||||
|
action.textContent = 'Next round';
|
||||||
|
action.className = 'primary big';
|
||||||
|
action.disabled = true;
|
||||||
|
if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlayers(s.players);
|
||||||
|
draw(s);
|
||||||
|
if (s.state === 'running') tone(parseFloat(s.multiplier));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayers(players) {
|
||||||
|
const wrap = $('players');
|
||||||
|
clear(wrap);
|
||||||
|
for (const p of players || []) {
|
||||||
|
const amount = p.cashed_out
|
||||||
|
? '↑ ' + parseFloat(p.cashed_out).toFixed(2) + '×'
|
||||||
|
: sats(p.stake_msat) + ' sats';
|
||||||
|
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
|
||||||
|
el('span', { class: 'who', text: p.nickname || 'anon' }),
|
||||||
|
el('span', { class: 'amt', text: amount })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAction() {
|
||||||
|
const hint = $('hint');
|
||||||
|
hint.textContent = '';
|
||||||
|
hint.className = 'hint';
|
||||||
|
try {
|
||||||
|
if (snapshot.state === 'betting_open' && !myBet) {
|
||||||
|
const r = await api('POST', '/api/bet', {
|
||||||
|
game: currentGame, stake_msat: stake, nickname,
|
||||||
|
});
|
||||||
|
setBalance(r.balance_msat);
|
||||||
|
myBet = 'in';
|
||||||
|
} else if (snapshot.state === 'running' && myBet === 'in') {
|
||||||
|
const r = await api('POST', '/api/cashout', { game: currentGame });
|
||||||
|
myBet = 'out';
|
||||||
|
hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`;
|
||||||
|
hint.className = 'hint good';
|
||||||
|
const b = await api('GET', '/api/balance');
|
||||||
|
setBalance(b.balance_msat);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
hint.textContent = e.message;
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- rendering ----------------
|
||||||
|
* Each game draws the same climb differently: a rocket fighting gravity, a
|
||||||
|
* craft spiralling inward, or a tower stacking upward. */
|
||||||
|
|
||||||
|
const canvas = $('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let stars = [];
|
||||||
|
|
||||||
|
function sizeCanvas() {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
canvas.width = canvas.clientWidth * dpr;
|
||||||
|
canvas.height = canvas.clientHeight * dpr;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
stars = Array.from({ length: 70 }, () => ({
|
||||||
|
x: Math.random(), y: Math.random(), r: Math.random() * 1.4 + 0.3,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', sizeCanvas);
|
||||||
|
|
||||||
|
function draw(s) {
|
||||||
|
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const m = parseFloat(s.multiplier) || 1;
|
||||||
|
const crashed = s.state === 'settled';
|
||||||
|
const progress = Math.min(1, Math.log(m) / Math.log(12));
|
||||||
|
|
||||||
|
// Starfield drifts downward as you climb.
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
for (const st of stars) {
|
||||||
|
const y = (st.y + progress * 0.9) % 1;
|
||||||
|
ctx.globalAlpha = 0.10 + st.r * 0.16;
|
||||||
|
ctx.fillRect(st.x * w, y * h, st.r, st.r);
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
if (currentGame === 'orbital') drawOrbital(w, h, progress, crashed);
|
||||||
|
else if (currentGame === 'tower') drawTower(w, h, progress, crashed);
|
||||||
|
else drawRocket(w, h, progress, crashed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRocket(w, h, p, crashed) {
|
||||||
|
const x = w * 0.5;
|
||||||
|
const y = h * (0.88 - p * 0.66);
|
||||||
|
const accent = crashed ? '#ff4d6d' : '#38f2e4';
|
||||||
|
|
||||||
|
// Exhaust plume: longer and more agitated as the climb steepens.
|
||||||
|
const plume = 26 + p * 60;
|
||||||
|
const g = ctx.createLinearGradient(x, y, x, y + plume);
|
||||||
|
g.addColorStop(0, crashed ? '#ff4d6daa' : '#38f2e4cc');
|
||||||
|
g.addColorStop(1, '#38f2e400');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - 7, y + 8);
|
||||||
|
ctx.lineTo(x + 7, y + 8);
|
||||||
|
ctx.lineTo(x + (Math.random() - 0.5) * 8, y + plume);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y - 18);
|
||||||
|
ctx.lineTo(x + 9, y + 10);
|
||||||
|
ctx.lineTo(x - 9, y + 10);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
if (crashed) {
|
||||||
|
ctx.strokeStyle = '#ff4d6d88';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const a = (i / 9) * Math.PI * 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x + Math.cos(a) * 34, y + Math.sin(a) * 34);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOrbital(w, h, p, crashed) {
|
||||||
|
const cx = w / 2, cy = h / 2;
|
||||||
|
const planet = Math.min(w, h) * 0.16;
|
||||||
|
ctx.fillStyle = '#1d2757';
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, planet, 0, Math.PI * 2); ctx.fill();
|
||||||
|
|
||||||
|
const orbit = planet + 12 + (1 - p) * Math.min(w, h) * 0.26;
|
||||||
|
ctx.strokeStyle = crashed ? '#ff4d6d55' : '#38f2e455';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
|
||||||
|
const a = p * Math.PI * 9;
|
||||||
|
const x = cx + Math.cos(a) * orbit, y = cy + Math.sin(a) * orbit;
|
||||||
|
ctx.fillStyle = crashed ? '#ff4d6d' : '#38f2e4';
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTower(w, h, p, crashed) {
|
||||||
|
const blocks = Math.floor(p * 16) + 1;
|
||||||
|
const bw = w * 0.28, bh = h * 0.05;
|
||||||
|
for (let i = 0; i < blocks; i++) {
|
||||||
|
const sway = Math.sin(i * 0.7 + p * 6) * (i / blocks) * 22 * (crashed ? 3 : 1);
|
||||||
|
const y = h * 0.9 - (i + 1) * bh;
|
||||||
|
ctx.fillStyle = i === blocks - 1
|
||||||
|
? (crashed ? '#ff4d6d' : '#38f2e4')
|
||||||
|
: `hsl(${230 + i * 4} 45% ${22 + i}%)`;
|
||||||
|
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- ambient sound ----------------
|
||||||
|
* Synthesised, so it never loops and ships no audio files. */
|
||||||
|
|
||||||
|
let audio = null;
|
||||||
|
|
||||||
|
function toggleSound() {
|
||||||
|
if (audio) {
|
||||||
|
audio.close(); audio = null;
|
||||||
|
$('sound-toggle').classList.remove('on');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audio = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const gain = audio.createGain();
|
||||||
|
gain.gain.value = 0.05;
|
||||||
|
gain.connect(audio.destination);
|
||||||
|
|
||||||
|
// Two detuned oscillators a fifth apart: calm, wide, and never resolving.
|
||||||
|
[55, 82.5].forEach((f) => {
|
||||||
|
const o = audio.createOscillator();
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.value = f;
|
||||||
|
const lfo = audio.createOscillator();
|
||||||
|
lfo.frequency.value = 0.05 + Math.random() * 0.06;
|
||||||
|
const depth = audio.createGain();
|
||||||
|
depth.gain.value = 1.5;
|
||||||
|
lfo.connect(depth).connect(o.frequency);
|
||||||
|
o.connect(gain);
|
||||||
|
o.start(); lfo.start();
|
||||||
|
});
|
||||||
|
$('sound-toggle').classList.add('on');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastTone = 0;
|
||||||
|
function tone(mult) {
|
||||||
|
if (!audio) return;
|
||||||
|
const now = audio.currentTime;
|
||||||
|
if (now - lastTone < 0.28) return;
|
||||||
|
lastTone = now;
|
||||||
|
const o = audio.createOscillator();
|
||||||
|
const g = audio.createGain();
|
||||||
|
o.type = 'triangle';
|
||||||
|
o.frequency.value = 220 * Math.min(4, mult);
|
||||||
|
g.gain.setValueAtTime(0.0001, now);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.03, now + 0.02);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
|
||||||
|
o.connect(g).connect(audio.destination);
|
||||||
|
o.start(now); o.stop(now + 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- scratch tickets ---------------- */
|
||||||
|
|
||||||
|
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
|
||||||
|
|
||||||
|
async function loadScratch() {
|
||||||
|
const { tickets } = await api('GET', '/api/scratch/catalog');
|
||||||
|
const wrap = $('tickets');
|
||||||
|
clear(wrap);
|
||||||
|
|
||||||
|
for (const t of tickets) {
|
||||||
|
const grid = el('div', { class: 'grid c' + t.cells });
|
||||||
|
for (let i = 0; i < t.cells; i++) {
|
||||||
|
grid.appendChild(el('div', { class: 'cell', text: '?' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = el('div', { class: 'result' });
|
||||||
|
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
|
||||||
|
button.dataset.play = t.id;
|
||||||
|
button.onclick = () => playScratch(t, grid, result);
|
||||||
|
|
||||||
|
const table = el('table', { class: 'odds' },
|
||||||
|
el('tr', {},
|
||||||
|
el('th', { text: 'prize' }),
|
||||||
|
el('th', { text: 'pays' }),
|
||||||
|
el('th', { text: 'chance' })));
|
||||||
|
for (const o of t.odds) {
|
||||||
|
table.appendChild(el('tr', {},
|
||||||
|
el('td', { text: o.tier }),
|
||||||
|
el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }),
|
||||||
|
el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' })));
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.appendChild(el('div', { class: 'card' },
|
||||||
|
el('h3', { text: t.name }),
|
||||||
|
el('p', { class: 'muted small', text: t.blurb }),
|
||||||
|
grid,
|
||||||
|
result,
|
||||||
|
button,
|
||||||
|
table,
|
||||||
|
el('p', { class: 'small' },
|
||||||
|
'Return to player: ',
|
||||||
|
el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }),
|
||||||
|
'. These odds are read from the same table that generates results.')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playScratch(t, grid, result) {
|
||||||
|
result.textContent = '';
|
||||||
|
result.className = 'result';
|
||||||
|
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
|
||||||
|
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
|
||||||
|
} catch (e) {
|
||||||
|
result.textContent = e.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBalance(out.balance_msat);
|
||||||
|
|
||||||
|
// Reveal cells one at a time — the outcome is already fixed, this is pacing.
|
||||||
|
const cells = out.outcome.cells;
|
||||||
|
const counts = {};
|
||||||
|
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
|
||||||
|
const winner = Object.keys(counts).find((k) => counts[k] >= 3);
|
||||||
|
|
||||||
|
cells.forEach((sym, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const cell = grid.children[i];
|
||||||
|
cell.textContent = SYMBOLS[sym];
|
||||||
|
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
|
||||||
|
if (i === cells.length - 1) {
|
||||||
|
const won = out.outcome.payout_msat > 0;
|
||||||
|
result.className = 'result' + (won ? ' win' : '');
|
||||||
|
result.textContent = won
|
||||||
|
? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
|
||||||
|
: 'No win this time';
|
||||||
|
}
|
||||||
|
}, i * 130);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- wallet ---------------- */
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const { entries } = await api('GET', '/api/history');
|
||||||
|
const wrap = $('history');
|
||||||
|
clear(wrap);
|
||||||
|
for (const e of entries || []) {
|
||||||
|
wrap.appendChild(el('div', { class: 'entry' },
|
||||||
|
el('span', { class: 'kind', text: e.Kind }),
|
||||||
|
el('span', {
|
||||||
|
class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'),
|
||||||
|
text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat),
|
||||||
|
}),
|
||||||
|
el('span', { class: 'after', text: sats(e.BalanceAfter) })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSats() {
|
||||||
|
const hint = $('send-hint');
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/transfer', {
|
||||||
|
to_pubkey: $('to-key').value.trim(),
|
||||||
|
amount_msat: Math.round(Number($('send-amt').value) * 1000),
|
||||||
|
});
|
||||||
|
setBalance(r.balance_msat);
|
||||||
|
hint.textContent = 'Sent.';
|
||||||
|
hint.className = 'hint good';
|
||||||
|
loadHistory();
|
||||||
|
} catch (e) {
|
||||||
|
hint.textContent = e.message;
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- verifier ----------------
|
||||||
|
* Recomputed here, in the browser, from published values only. */
|
||||||
|
|
||||||
|
async function sha256(bytes) {
|
||||||
|
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256(keyBytes, msg) {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
|
return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function concat(...arrays) {
|
||||||
|
const total = arrays.reduce((n, a) => n + a.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let off = 0;
|
||||||
|
for (const a of arrays) { out.set(a, off); off += a.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kvRow(label, value) {
|
||||||
|
return el('div', { class: 'kv' },
|
||||||
|
el('span', { text: label }),
|
||||||
|
el('code', { text: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRound() {
|
||||||
|
const out = $('verify-out');
|
||||||
|
clear(out);
|
||||||
|
out.appendChild(el('p', { text: 'checking…' }));
|
||||||
|
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await api('GET', '/api/verify/' + Number($('verify-id').value));
|
||||||
|
} catch (e) {
|
||||||
|
clear(out);
|
||||||
|
out.appendChild(el('p', { class: 'bad', text: e.message }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. The revealed seed must hash to the commitment published beforehand.
|
||||||
|
const seedBytes = unhex(r.server_seed);
|
||||||
|
const seedHash = hex(await sha256(seedBytes));
|
||||||
|
const commitOK = seedHash === r.commitment;
|
||||||
|
|
||||||
|
// 2. The client seed must be the hash over participants, length-prefixed.
|
||||||
|
const parts = [];
|
||||||
|
for (const p of r.participants || []) {
|
||||||
|
const pk = unhex(p);
|
||||||
|
const len = new Uint8Array(4);
|
||||||
|
new DataView(len.buffer).setUint32(0, pk.length, false);
|
||||||
|
parts.push(len, pk);
|
||||||
|
}
|
||||||
|
const clientSeed = await sha256(concat(...parts));
|
||||||
|
const clientOK = hex(clientSeed) === r.client_seed;
|
||||||
|
|
||||||
|
// 3. The round seed follows from both, and determines the crash point.
|
||||||
|
const nonceBytes = new Uint8Array(8);
|
||||||
|
new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false);
|
||||||
|
const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes));
|
||||||
|
|
||||||
|
const allOK = commitOK && clientOK;
|
||||||
|
clear(out);
|
||||||
|
out.appendChild(el('p', {
|
||||||
|
class: allOK ? 'ok' : 'bad',
|
||||||
|
text: allOK ? '✓ This round checks out.' : '✗ Verification failed.',
|
||||||
|
}));
|
||||||
|
out.appendChild(kvRow('commitment', r.commitment));
|
||||||
|
out.appendChild(kvRow('sha256(seed)', seedHash));
|
||||||
|
out.appendChild(kvRow('client seed', r.client_seed || ''));
|
||||||
|
out.appendChild(kvRow('recomputed', hex(clientSeed)));
|
||||||
|
out.appendChild(kvRow('round seed', hex(roundSeed)));
|
||||||
|
out.appendChild(kvRow('crash point',
|
||||||
|
r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'));
|
||||||
|
out.appendChild(el('p', {
|
||||||
|
class: 'muted small',
|
||||||
|
text: 'Computed on this device. The server was asked only for the published '
|
||||||
|
+ 'values, not for its opinion.',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- wiring ---------------- */
|
||||||
|
|
||||||
|
function selectTab(view) {
|
||||||
|
document.querySelectorAll('.tab').forEach((t) =>
|
||||||
|
t.classList.toggle('active', t.dataset.view === view));
|
||||||
|
document.querySelectorAll('.view').forEach((v) =>
|
||||||
|
(v.hidden = v.id !== 'view-' + view));
|
||||||
|
if (view === 'wallet') loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStake(v) {
|
||||||
|
stake = v;
|
||||||
|
document.querySelectorAll('.chip').forEach((c) =>
|
||||||
|
c.classList.toggle('on', Number(c.dataset.stake) === v));
|
||||||
|
document.querySelectorAll('[data-play]').forEach((b) => {
|
||||||
|
b.textContent = `Scratch for ${sats(stake)} sats`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
sizeCanvas();
|
||||||
|
keypair = await loadOrCreateKey();
|
||||||
|
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
|
||||||
|
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
|
||||||
|
|
||||||
|
$('enter').onclick = () => signIn().catch((e) => {
|
||||||
|
$('keynote').textContent = e.message;
|
||||||
|
});
|
||||||
|
$('action').onclick = onAction;
|
||||||
|
$('sound-toggle').onclick = toggleSound;
|
||||||
|
$('send').onclick = sendSats;
|
||||||
|
$('do-verify').onclick = verifyRound;
|
||||||
|
$('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex);
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab').forEach((t) =>
|
||||||
|
(t.onclick = () => selectTab(t.dataset.view)));
|
||||||
|
document.querySelectorAll('.chip').forEach((c) =>
|
||||||
|
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||||||
|
|
||||||
|
const pick = $('gamepick');
|
||||||
|
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
||||||
|
const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
|
||||||
|
b.onclick = () => {
|
||||||
|
document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
|
||||||
|
b.classList.add('on');
|
||||||
|
connect(id);
|
||||||
|
};
|
||||||
|
pick.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
setStake(stake);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
141
cmd/arcade/static/index.html
Normal file
141
cmd/arcade/static/index.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Quantum Arcade</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Ornate background filigree, drawn once as an SVG pattern and tiled. -->
|
||||||
|
<svg class="filigree" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="orn" width="120" height="120" patternUnits="userSpaceOnUse">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="0.6">
|
||||||
|
<circle cx="60" cy="60" r="46"/>
|
||||||
|
<circle cx="60" cy="60" r="30"/>
|
||||||
|
<circle cx="60" cy="60" r="14"/>
|
||||||
|
<path d="M60 0 L60 120 M0 60 L120 60"/>
|
||||||
|
<path d="M17 17 L103 103 M103 17 L17 103"/>
|
||||||
|
<path d="M60 14 q26 22 0 46 q-26-24 0-46"/>
|
||||||
|
<path d="M14 60 q22 26 46 0 q-24-26-46 0"/>
|
||||||
|
</g>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">QUANTUM<span>ARCADE</span></div>
|
||||||
|
<div class="balance" id="balance-wrap" hidden>
|
||||||
|
<span class="label">balance</span>
|
||||||
|
<span class="value" id="balance">—</span>
|
||||||
|
</div>
|
||||||
|
<button class="sound" id="sound-toggle" title="Ambient sound">♪</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Sign-in: a nickname and nothing else. -->
|
||||||
|
<section class="panel center" id="signin">
|
||||||
|
<h1>Enter the arcade</h1>
|
||||||
|
<p class="muted">
|
||||||
|
No account, no email, no password. Your device generates a key that
|
||||||
|
<em>is</em> your identity. Keep the device, keep the balance.
|
||||||
|
</p>
|
||||||
|
<input id="nickname" maxlength="20" placeholder="pick a name" autocomplete="off">
|
||||||
|
<button class="primary" id="enter">Enter</button>
|
||||||
|
<p class="fineprint" id="keynote"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main id="app" hidden>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-view="crash">Crash</button>
|
||||||
|
<button class="tab" data-view="scratch">Scratchers</button>
|
||||||
|
<button class="tab" data-view="wallet">Wallet</button>
|
||||||
|
<button class="tab" data-view="verify">Verify</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ============ CRASH ============ -->
|
||||||
|
<section class="view" id="view-crash">
|
||||||
|
<div class="gamepick" id="gamepick"></div>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
<div class="readout">
|
||||||
|
<div class="multiplier" id="multiplier">1.00×</div>
|
||||||
|
<div class="state" id="state">connecting…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="stakerow">
|
||||||
|
<button class="chip" data-stake="1000">1</button>
|
||||||
|
<button class="chip" data-stake="5000">5</button>
|
||||||
|
<button class="chip" data-stake="25000">25</button>
|
||||||
|
<button class="chip" data-stake="100000">100</button>
|
||||||
|
<span class="unit">sats</span>
|
||||||
|
</div>
|
||||||
|
<button class="primary big" id="action">Place bet</button>
|
||||||
|
<div class="hint" id="hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="players" id="players"></div>
|
||||||
|
|
||||||
|
<details class="proof">
|
||||||
|
<summary>Fairness for this round</summary>
|
||||||
|
<div class="kv"><span>commitment</span><code id="commitment">—</code></div>
|
||||||
|
<div class="kv"><span>revealed seed</span><code id="revealed">sealed until the round ends</code></div>
|
||||||
|
<p class="muted small">
|
||||||
|
The commitment is published before betting opens. The crash point is
|
||||||
|
derived from that seed combined with every player's key — so it cannot
|
||||||
|
be chosen after seeing who joined.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ SCRATCH ============ -->
|
||||||
|
<section class="view" id="view-scratch" hidden>
|
||||||
|
<div id="tickets"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ WALLET ============ -->
|
||||||
|
<section class="view" id="view-wallet" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Your key</h2>
|
||||||
|
<p class="muted small">Share this so friends can send you sats.</p>
|
||||||
|
<code class="pubkey" id="pubkey">—</code>
|
||||||
|
<button id="copykey">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Send sats</h2>
|
||||||
|
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
||||||
|
<input id="send-amt" type="number" min="1" placeholder="amount in sats">
|
||||||
|
<button class="primary" id="send">Send</button>
|
||||||
|
<div class="hint" id="send-hint"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Every change to your balance</h2>
|
||||||
|
<div id="history" class="history"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ VERIFY ============ -->
|
||||||
|
<section class="view" id="view-verify" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Check any round</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Enter a round number. Your phone recomputes the outcome from the
|
||||||
|
published seeds — it does not take the server's word for anything.
|
||||||
|
</p>
|
||||||
|
<input id="verify-id" type="number" min="1" placeholder="round number">
|
||||||
|
<button class="primary" id="do-verify">Verify</button>
|
||||||
|
<div id="verify-out" class="verify-out"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
255
cmd/arcade/static/style.css
Normal file
255
cmd/arcade/static/style.css
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/* Quantum Arcade — obsidian and indigo, dense ornament, and vivid colour
|
||||||
|
reserved strictly for the things that matter: the multiplier, the balance,
|
||||||
|
and the button that takes your money out of danger. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--void: #05060d;
|
||||||
|
--obsidian: #0a0c18;
|
||||||
|
--indigo: #131a3a;
|
||||||
|
--indigo-hi: #1d2757;
|
||||||
|
--ink: #c8cbe6;
|
||||||
|
--muted: #6a719c;
|
||||||
|
--cyan: #38f2e4;
|
||||||
|
--magenta: #ff3ec8;
|
||||||
|
--gold: #ffc857;
|
||||||
|
--danger: #ff4d6d;
|
||||||
|
--ornament: #1a2350;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: radial-gradient(ellipse at 50% -10%, var(--indigo) 0%, var(--obsidian) 45%, var(--void) 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The ornament sits behind everything, busy but very low contrast. */
|
||||||
|
.filigree {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
color: var(--ornament);
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > * { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* ---------- chrome ---------- */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 14px 16px calc(14px);
|
||||||
|
border-bottom: 1px solid #ffffff10;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700; letter-spacing: 0.22em; font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.brand span { color: var(--cyan); margin-left: 6px; }
|
||||||
|
|
||||||
|
.balance { margin-left: auto; text-align: right; line-height: 1.1; }
|
||||||
|
.balance .label {
|
||||||
|
display: block; font-size: 9px; letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase; color: var(--muted);
|
||||||
|
}
|
||||||
|
.balance .value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 18px; font-weight: 700; color: var(--cyan);
|
||||||
|
text-shadow: 0 0 18px #38f2e455;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sound {
|
||||||
|
background: none; border: 1px solid #ffffff20; color: var(--muted);
|
||||||
|
width: 34px; height: 34px; border-radius: 50%; font-size: 15px;
|
||||||
|
}
|
||||||
|
.sound.on { color: var(--cyan); border-color: var(--cyan); }
|
||||||
|
|
||||||
|
/* ---------- panels ---------- */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
max-width: 460px; margin: 0 auto; padding: 40px 22px;
|
||||||
|
}
|
||||||
|
.center { text-align: center; }
|
||||||
|
|
||||||
|
h1 { font-size: 26px; margin: 0 0 10px; letter-spacing: 0.02em; }
|
||||||
|
h2 { font-size: 14px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--muted); margin: 0 0 10px; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 12.5px; }
|
||||||
|
.fineprint { font-size: 11px; color: var(--muted); margin-top: 18px; word-break: break-all; }
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%; padding: 13px 14px; margin: 8px 0;
|
||||||
|
background: #ffffff08; border: 1px solid #ffffff18; border-radius: 10px;
|
||||||
|
color: var(--ink); font-size: 16px; /* 16px stops iOS zooming on focus */
|
||||||
|
}
|
||||||
|
input:focus { outline: none; border-color: var(--cyan); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit; cursor: pointer; border-radius: 10px;
|
||||||
|
border: 1px solid #ffffff20; background: #ffffff0c; color: var(--ink);
|
||||||
|
padding: 11px 16px;
|
||||||
|
}
|
||||||
|
button:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: linear-gradient(135deg, var(--cyan), #21b6ff);
|
||||||
|
color: #04121a; border: none; font-weight: 700; letter-spacing: 0.04em;
|
||||||
|
box-shadow: 0 0 26px #38f2e444;
|
||||||
|
width: 100%; padding: 15px;
|
||||||
|
}
|
||||||
|
.primary.big { font-size: 17px; padding: 18px; }
|
||||||
|
.primary.cashout {
|
||||||
|
background: linear-gradient(135deg, var(--magenta), var(--gold));
|
||||||
|
color: #1a0512; box-shadow: 0 0 34px #ff3ec866;
|
||||||
|
animation: urge 900ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.primary:disabled { opacity: 0.4; box-shadow: none; animation: none; }
|
||||||
|
|
||||||
|
@keyframes urge {
|
||||||
|
0%, 100% { box-shadow: 0 0 26px #ff3ec855; }
|
||||||
|
50% { box-shadow: 0 0 40px #ff3ec8aa; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- tabs ---------- */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 6px; padding: 12px 12px 0;
|
||||||
|
max-width: 620px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
flex: 1; padding: 10px 4px; font-size: 12px; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; background: none; border: none; color: var(--muted);
|
||||||
|
border-bottom: 2px solid transparent; border-radius: 0;
|
||||||
|
}
|
||||||
|
.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); }
|
||||||
|
|
||||||
|
.view { max-width: 620px; margin: 0 auto; padding: 14px 14px 60px; }
|
||||||
|
|
||||||
|
/* ---------- crash stage ---------- */
|
||||||
|
|
||||||
|
.gamepick { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.gamepick button {
|
||||||
|
flex: 1; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
padding: 9px 4px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.gamepick button.on { color: var(--cyan); border-color: var(--cyan); background: #38f2e412; }
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative; border-radius: 16px; overflow: hidden;
|
||||||
|
border: 1px solid #ffffff14;
|
||||||
|
background: linear-gradient(180deg, #070a16 0%, #05060d 100%);
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
#canvas { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
.readout {
|
||||||
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; pointer-events: none;
|
||||||
|
}
|
||||||
|
.multiplier {
|
||||||
|
font-size: clamp(46px, 17vw, 88px); font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
|
||||||
|
color: var(--cyan); text-shadow: 0 0 40px #38f2e466;
|
||||||
|
}
|
||||||
|
.multiplier.crashed { color: var(--danger); text-shadow: 0 0 40px #ff4d6d66; }
|
||||||
|
.multiplier.won { color: var(--gold); text-shadow: 0 0 46px #ffc85777; }
|
||||||
|
.state {
|
||||||
|
font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase;
|
||||||
|
color: var(--muted); margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls { margin-top: 14px; }
|
||||||
|
.stakerow { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
|
||||||
|
.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 12px 4px; }
|
||||||
|
.chip.on { border-color: var(--cyan); color: var(--cyan); background: #38f2e414; }
|
||||||
|
.unit { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
.hint { min-height: 18px; margin-top: 8px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||||
|
.hint.bad { color: var(--danger); }
|
||||||
|
.hint.good { color: var(--gold); }
|
||||||
|
|
||||||
|
.players { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.player {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||||
|
background: #ffffff06; border: 1px solid #ffffff10; border-radius: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.player .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.player .amt { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||||
|
.player.out .amt { color: var(--gold); }
|
||||||
|
|
||||||
|
.proof { margin-top: 18px; }
|
||||||
|
.proof summary {
|
||||||
|
cursor: pointer; font-size: 11px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--muted); padding: 8px 0;
|
||||||
|
}
|
||||||
|
.kv { display: flex; gap: 10px; font-size: 11px; padding: 4px 0; }
|
||||||
|
.kv span { color: var(--muted); min-width: 96px; }
|
||||||
|
.kv code { word-break: break-all; color: var(--ink); opacity: 0.8; }
|
||||||
|
|
||||||
|
/* ---------- scratch ---------- */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff07; border: 1px solid #ffffff14;
|
||||||
|
border-radius: 14px; padding: 16px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.card h3 { margin: 0 0 4px; font-size: 18px; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid; gap: 8px; margin: 14px 0;
|
||||||
|
}
|
||||||
|
.grid.c9 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.grid.c6 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
aspect-ratio: 1; display: grid; place-items: center;
|
||||||
|
font-size: 26px; border-radius: 10px;
|
||||||
|
background: linear-gradient(140deg, var(--indigo-hi), var(--indigo));
|
||||||
|
border: 1px solid #ffffff14;
|
||||||
|
transition: transform 160ms ease, background 260ms ease;
|
||||||
|
}
|
||||||
|
.cell.revealed { background: #05060d; border-color: #ffffff22; }
|
||||||
|
.cell.hit { border-color: var(--gold); box-shadow: 0 0 18px #ffc85755; }
|
||||||
|
|
||||||
|
.odds { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
|
||||||
|
.odds th, .odds td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #ffffff10; }
|
||||||
|
.odds th { color: var(--muted); font-weight: 500; font-size: 10px;
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase; }
|
||||||
|
.odds td:last-child, .odds th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.rtp { color: var(--gold); font-weight: 700; }
|
||||||
|
|
||||||
|
.result { text-align: center; padding: 10px 0; font-size: 16px; }
|
||||||
|
.result.win { color: var(--gold); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ---------- wallet ---------- */
|
||||||
|
|
||||||
|
.pubkey {
|
||||||
|
display: block; word-break: break-all; font-size: 11px;
|
||||||
|
background: #00000055; padding: 10px; border-radius: 8px; margin: 8px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.entry {
|
||||||
|
display: flex; gap: 10px; font-size: 12px; padding: 8px 10px;
|
||||||
|
background: #ffffff05; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.entry .kind { flex: 1; color: var(--muted); }
|
||||||
|
.entry .delta { font-variant-numeric: tabular-nums; }
|
||||||
|
.entry .delta.pos { color: var(--cyan); }
|
||||||
|
.entry .delta.neg { color: var(--muted); }
|
||||||
|
.entry .after { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 74px; text-align: right; }
|
||||||
|
|
||||||
|
.verify-out { margin-top: 12px; font-size: 12px; }
|
||||||
|
.verify-out .ok { color: var(--cyan); font-weight: 700; }
|
||||||
|
.verify-out .bad { color: var(--danger); font-weight: 700; }
|
||||||
|
.verify-out .kv code { font-size: 10px; }
|
||||||
@@ -26,6 +26,19 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
arcade:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
ARCADE_DSN: postgres://arcade:arcade_dev@postgres:5432/arcade
|
||||||
|
ARCADE_ADDR: ":8080"
|
||||||
|
# Development funding. Leave unset in any real deployment.
|
||||||
|
ARCADE_DEV_FAUCET: "${ARCADE_DEV_FAUCET:-0}"
|
||||||
|
ports: ["8080:8080"]
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
redisdata:
|
redisdata:
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -5,6 +5,7 @@ go 1.26.5
|
|||||||
require github.com/jackc/pgx/v5 v5.10.0
|
require github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coder/websocket v1.8.15 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
58
migrations/0002_rounds.sql
Normal file
58
migrations/0002_rounds.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- Rounds, bets, and the verification record for every settled outcome.
|
||||||
|
--
|
||||||
|
-- Seeds are stored so that any round can be re-verified indefinitely. The
|
||||||
|
-- server seed column is NULL until the round settles: revealing it early would
|
||||||
|
-- let a player compute the outcome before betting closes.
|
||||||
|
|
||||||
|
CREATE TABLE rounds (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game TEXT NOT NULL, -- 'rocket', 'orbital', 'tower'
|
||||||
|
nonce BIGINT NOT NULL,
|
||||||
|
commitment BYTEA NOT NULL, -- SHA-256 of the server seed
|
||||||
|
server_seed BYTEA, -- revealed only after settlement
|
||||||
|
client_seed BYTEA, -- derived from participants
|
||||||
|
crash_point BIGINT, -- Q32.32 fixed-point
|
||||||
|
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
locked_at TIMESTAMPTZ,
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT reveal_is_complete CHECK (
|
||||||
|
settled_at IS NULL OR (server_seed IS NOT NULL AND crash_point IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bets (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
round_id BIGINT NOT NULL REFERENCES rounds(id),
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
stake_msat BIGINT NOT NULL CHECK (stake_msat > 0),
|
||||||
|
-- Set when the player cashes out; NULL means they rode it to the crash.
|
||||||
|
cashout_at BIGINT, -- Q32.32 multiplier
|
||||||
|
payout_msat BIGINT,
|
||||||
|
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (round_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX bets_round_idx ON bets (round_id);
|
||||||
|
CREATE INDEX bets_account_idx ON bets (account_id, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE scratch_plays (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
nonce BIGINT NOT NULL,
|
||||||
|
commitment BYTEA NOT NULL,
|
||||||
|
server_seed BYTEA NOT NULL,
|
||||||
|
stake_msat BIGINT NOT NULL CHECK (stake_msat > 0),
|
||||||
|
tier_name TEXT NOT NULL,
|
||||||
|
payout_msat BIGINT NOT NULL CHECK (payout_msat >= 0),
|
||||||
|
cells INTEGER[] NOT NULL,
|
||||||
|
played_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX scratch_account_idx ON scratch_plays (account_id, id DESC);
|
||||||
|
|
||||||
|
-- Rounds and plays are historical records; they are never rewritten.
|
||||||
|
CREATE TRIGGER scratch_plays_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON scratch_plays
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
||||||
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.
|
// TickHz is the simulation rate. Rounds advance in whole ticks only.
|
||||||
const TickHz = 60
|
const TickHz = 60
|
||||||
|
|
||||||
// growthPerTickBP is multiplier growth per tick, in basis points of the current
|
// RoundTicks is the hard ceiling on a round's length: 60 seconds at 60Hz.
|
||||||
// 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.
|
// The multiplier follows a hyperbolic curve that diverges at exactly this
|
||||||
const growthPerTickBP int64 = 6
|
// 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
|
// CrashPoint derives the multiplier at which a round ends, as a pure function of
|
||||||
// the seed.
|
// the seed.
|
||||||
@@ -48,31 +55,49 @@ func CrashPoint(seed [32]byte) fixed.F {
|
|||||||
return cp
|
return cp
|
||||||
}
|
}
|
||||||
|
|
||||||
// step is the per-tick growth factor, 1 + growthPerTickBP/10000, in Q32.32.
|
// MultiplierAt returns the multiplier displayed at a given tick.
|
||||||
func step() fixed.F {
|
//
|
||||||
return fixed.One + fixed.F(growthPerTickBP<<32/10000)
|
// m(t) = 1 / (1 - t/T)^2
|
||||||
}
|
//
|
||||||
|
// It starts at 1.0, rises slowly at first, and accelerates without bound as t
|
||||||
// MultiplierAt returns the multiplier displayed at a given tick of the round,
|
// approaches T. That acceleration is the tension: the longer you hold, the
|
||||||
// compounding from 1.0.
|
// 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 {
|
func MultiplierAt(tick int) fixed.F {
|
||||||
m := fixed.One
|
if tick <= 0 {
|
||||||
s := step()
|
return fixed.One
|
||||||
for i := 0; i < tick; i++ {
|
|
||||||
m = m.Mul(s)
|
|
||||||
}
|
}
|
||||||
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 {
|
func TicksToMultiplier(m fixed.F) int {
|
||||||
cur := fixed.One
|
if m <= fixed.One {
|
||||||
s := step()
|
return 0
|
||||||
for tick := 0; tick < 1_000_000; tick++ {
|
}
|
||||||
if cur >= m {
|
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
|
return tick
|
||||||
}
|
}
|
||||||
cur = cur.Mul(s)
|
|
||||||
}
|
|
||||||
return 1_000_000
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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