diff --git a/README.md b/README.md index ee0251d..5e64b04 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,18 @@ 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; +stacking tower — plus instant scratch tickets to play between rounds. Set an +auto cash-out target before the round and it closes at exactly your number, or +ride it and tap out by hand. Identity is a keypair your browser generates; there is no account, no email, and no password. +The house edge is **1%** on everything. That is better than essentially +anything commercial, and it is deliberate: this is a game among friends, not a +revenue stream. + +Everything is API-first — the browser client uses the same endpoints anyone +else can. Write a bot in twenty lines; see [docs/API.md](docs/API.md). + Everything runs on one machine: one Go binary with the client embedded, PostgreSQL, and Redis. @@ -77,7 +85,7 @@ One binary, with enforced internal boundaries: | `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 | +| `pkg/room` | Round lifecycle, auto cash-out, 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 @@ -101,6 +109,20 @@ 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. +## Testing + +```bash +make test # unit and integration +make test-race # under the race detector +make cover # coverage per package +make db-reset # wipe the ledger; it is append-only and accumulates +``` + +Coverage sits around 85%, and the tests found and pinned three real money +bugs: a posting set that minted 18 quintillion millisatoshis by wrapping the +zero-sum check, a crash point that overflowed negative at the rarest seed, and +scratch tables that advertised 98% while paying 56%. + ## Status Built and tested: @@ -109,7 +131,9 @@ Built and tested: - three crash games with live multiplayer rounds - two scratch tickets with verified-honest odds - keypair identity, peer-to-peer transfers, transaction history +- auto cash-out targets that pay your exact number - in-browser verifier +- a documented public API, good enough to write bots against Not yet built: diff --git a/cmd/arcade/e2e_test.go b/cmd/arcade/e2e_test.go index a0f95c4..18406cd 100644 --- a/cmd/arcade/e2e_test.go +++ b/cmd/arcade/e2e_test.go @@ -278,3 +278,67 @@ func itoa(v int64) string { } return string(buf[i:]) } + +// An auto cash-out target must be accepted by the API and reflected in the +// round, and an invalid one must be refused before any money moves. +func TestAutoCashOutThroughTheAPI(t *testing.T) { + c := newClient(t) + c.signIn("autoplayer") + start := c.fund(50_000_000) + + var out map[string]any + // A target at or below 1.00 is meaningless and must be rejected. + code := c.do("POST", "/api/bet", map[string]any{ + "game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1.0, + }, &out) + if code == 200 { + t.Fatal("a 1.00x auto cash-out target was accepted") + } + + // An absurd target must be refused rather than overflowing the conversion. + code = c.do("POST", "/api/bet", map[string]any{ + "game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1e12, + }, &out) + if code == 200 { + t.Fatal("an absurd auto cash-out target was accepted") + } + + // Neither rejection may have moved money. + var bal struct { + BalanceMsat int64 `json:"balance_msat"` + } + c.do("GET", "/api/balance", nil, &bal) + if bal.BalanceMsat != start { + t.Fatalf("balance = %d after rejected bets, want %d", bal.BalanceMsat, start) + } + + // A sensible target should be accepted during a betting window. + deadline := time.Now().Add(90 * time.Second) + placed := false + for time.Now().Before(deadline) && !placed { + var games struct { + Rooms []struct { + Game string `json:"game"` + State string `json:"state"` + } `json:"rooms"` + } + c.do("GET", "/api/games", nil, &games) + for _, rm := range games.Rooms { + if rm.Game == "rocket" && rm.State == "betting_open" { + var res map[string]any + if code := c.do("POST", "/api/bet", map[string]any{ + "game": "rocket", "stake_msat": 1_000_000, + "auto_cashout": 2.5, "nickname": "autoplayer", + }, &res); code == 200 { + placed = true + } + } + } + if !placed { + time.Sleep(400 * time.Millisecond) + } + } + if !placed { + t.Fatal("could not place an auto cash-out bet within 90s") + } +} diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index 2eda82e..3621b31 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -21,6 +21,7 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "github.com/drjones/quantum-arcade/pkg/fair" + "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/identity" "github.com/drjones/quantum-arcade/pkg/ledger" "github.com/drjones/quantum-arcade/pkg/room" @@ -31,6 +32,10 @@ import ( //go:embed static var staticFiles embed.FS +// maxAutoCashOut bounds the target a client may request, keeping the +// conversion to fixed-point well inside the representable range. +const maxAutoCashOut = 1_000_000 + // Games offered as shared rounds. They share one engine and differ in how the // client renders the climb. var games = []string{"rocket", "orbital", "tower"} @@ -361,6 +366,9 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) { Game string `json:"game"` StakeMsat int64 `json:"stake_msat"` Nickname string `json:"nickname"` + // AutoCashOut is an optional target multiplier, e.g. 2.5 for 2.50x. + // Zero or absent means no target. + AutoCashOut float64 `json:"auto_cashout"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "malformed request") @@ -371,7 +379,18 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "no such game") return } - if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat); err != nil { + // Convert the target to fixed-point at the boundary; everything past this + // point is integer arithmetic. + var target fixed.F + if req.AutoCashOut > 0 { + if req.AutoCashOut > float64(maxAutoCashOut) { + writeErr(w, http.StatusBadRequest, "auto cash-out target is too large") + return + } + target = fixed.F(req.AutoCashOut * float64(fixed.One)) + } + + if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat, target); err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } diff --git a/cmd/arcade/static/app.js b/cmd/arcade/static/app.js index cadaa6e..d84c499 100644 --- a/cmd/arcade/static/app.js +++ b/cmd/arcade/static/app.js @@ -115,6 +115,17 @@ function setBalance(msat) { $('balance').textContent = sats(msat); } +/* Reads the auto cash-out box. Returns 0 when empty or invalid, which the + * server treats as "no target". */ +function autoTarget() { + const v = parseFloat($('auto-target').value); + return Number.isFinite(v) && v > 1 ? v : 0; +} + +function refreshAutoRow() { + $('autorow').classList.toggle('armed', autoTarget() > 0); +} + /* ---------------- crash room ---------------- */ function connect(game) { @@ -145,7 +156,9 @@ function onSnapshot(s) { 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.textContent = myBet + ? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck') + : 'Place bet'; action.className = 'primary big'; action.disabled = !!myBet; break; @@ -189,7 +202,7 @@ function renderPlayers(players) { clear(wrap); for (const p of players || []) { const amount = p.cashed_out - ? '↑ ' + parseFloat(p.cashed_out).toFixed(2) + '×' + ? (p.auto ? '⚡ ' : '↑ ') + 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' }), @@ -205,6 +218,7 @@ async function onAction() { if (snapshot.state === 'betting_open' && !myBet) { const r = await api('POST', '/api/bet', { game: currentGame, stake_msat: stake, nickname, + auto_cashout: autoTarget(), }); setBalance(r.balance_msat); myBet = 'in'; @@ -250,11 +264,23 @@ function draw(s) { 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'; + // Scrolling grid: a horizon that rushes past as the multiplier climbs. + ctx.strokeStyle = '#0a7a5233'; + ctx.lineWidth = 1; + const spacing = 34; + const offset = (progress * 260) % spacing; + for (let x = 0; x <= w; x += spacing) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); + } + for (let y = -spacing + offset; y <= h; y += spacing) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); + } + + // Sparse drifting particles, phosphor green. + ctx.fillStyle = '#00ff9c'; for (const st of stars) { const y = (st.y + progress * 0.9) % 1; - ctx.globalAlpha = 0.10 + st.r * 0.16; + ctx.globalAlpha = 0.12 + st.r * 0.18; ctx.fillRect(st.x * w, y * h, st.r, st.r); } ctx.globalAlpha = 1; @@ -267,13 +293,13 @@ function draw(s) { function drawRocket(w, h, p, crashed) { const x = w * 0.5; const y = h * (0.88 - p * 0.66); - const accent = crashed ? '#ff4d6d' : '#38f2e4'; + const accent = crashed ? '#ff3355' : '#00ff9c'; // 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'); + g.addColorStop(0, crashed ? '#ff3355aa' : '#00ff9ccc'); + g.addColorStop(1, '#00ff9c00'); ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(x - 7, y + 8); @@ -291,7 +317,7 @@ function drawRocket(w, h, p, crashed) { ctx.fill(); if (crashed) { - ctx.strokeStyle = '#ff4d6d88'; + ctx.strokeStyle = '#ff335588'; ctx.lineWidth = 2; for (let i = 0; i < 9; i++) { const a = (i / 9) * Math.PI * 2; @@ -306,17 +332,17 @@ function drawRocket(w, h, p, crashed) { 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.fillStyle = '#00291c'; 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.strokeStyle = crashed ? '#ff335555' : '#00ff9c55'; 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.fillStyle = crashed ? '#ff3355' : '#00ff9c'; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); } @@ -327,8 +353,8 @@ function drawTower(w, h, p, crashed) { 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}%)`; + ? (crashed ? '#ff3355' : '#00ff9c') + : `hsl(${160 - i} 90% ${12 + i}%)`; ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2); } } @@ -618,6 +644,7 @@ async function init() { (t.onclick = () => selectTab(t.dataset.view))); document.querySelectorAll('.chip').forEach((c) => (c.onclick = () => setStake(Number(c.dataset.stake)))); + $('auto-target').oninput = refreshAutoRow; const pick = $('gamepick'); [['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => { diff --git a/cmd/arcade/static/index.html b/cmd/arcade/static/index.html index f9cb98b..09c6199 100644 --- a/cmd/arcade/static/index.html +++ b/cmd/arcade/static/index.html @@ -3,7 +3,7 @@ -Quantum Arcade +QUANTUM ARCADE @@ -11,15 +11,13 @@

Enter the arcade

-

- No account, no email, no password. Your device generates a key that +

ACCESS TERMINAL

+

+ No account. No email. No password. This device generated a keypair that is your identity. Keep the device, keep the balance.

- - + +

@@ -76,6 +74,13 @@ sats +
+ + + × +
+
diff --git a/cmd/arcade/static/style.css b/cmd/arcade/static/style.css index 74fe139..5ac78d1 100644 --- a/cmd/arcade/static/style.css +++ b/cmd/arcade/static/style.css @@ -1,19 +1,20 @@ -/* 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. */ +/* Quantum Arcade — terminal aesthetic. + * + * Phosphor green on black, monospace everywhere, CRT scanlines and a faint + * flicker. Colour is rationed: amber for money at risk, magenta for the + * multiplier, red for the crash. Everything else is green on black, because + * a terminal that shouts about everything says nothing. */ :root { - --void: #05060d; - --obsidian: #0a0c18; - --indigo: #131a3a; - --indigo-hi: #1d2757; - --ink: #c8cbe6; - --muted: #6a719c; - --cyan: #38f2e4; - --magenta: #ff3ec8; - --gold: #ffc857; - --danger: #ff4d6d; - --ornament: #1a2350; + --black: #000000; + --panel: #030806; + --green: #00ff9c; + --green-dim: #0a7a52; + --green-ghost: #063a29; + --amber: #ffb000; + --magenta: #ff2e88; + --red: #ff3355; + --grid: #06181200; } * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } @@ -21,132 +22,178 @@ 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; + background: var(--black); + color: var(--green); + font: 14px/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; overscroll-behavior: none; + text-shadow: 0 0 6px #00ff9c40; } -/* The ornament sits behind everything, busy but very low contrast. */ +/* Faint hex-grid ornament behind everything. */ .filigree { position: fixed; inset: 0; width: 100%; height: 100%; - color: var(--ornament); - opacity: 0.55; + color: #0d2b21; + opacity: 0.5; pointer-events: none; z-index: 0; } +/* CRT scanlines and a slow flicker over the whole page. */ +body::after { + content: ""; + position: fixed; inset: 0; z-index: 50; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + #00ff9c08 0px, #00ff9c08 1px, + transparent 1px, transparent 3px); + animation: flicker 5s infinite steps(60); +} + +@keyframes flicker { + 0%, 96%, 100% { opacity: 0.5; } + 97% { opacity: 0.75; } + 98% { opacity: 0.35; } +} + 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); + padding: 10px 14px; + border-bottom: 1px solid var(--green-ghost); + background: #00120c; } .brand { - font-weight: 700; letter-spacing: 0.22em; font-size: 13px; - color: var(--ink); + font-weight: 700; letter-spacing: 0.18em; font-size: 12px; } -.brand span { color: var(--cyan); margin-left: 6px; } +.brand::before { content: "> "; color: var(--green-dim); } +.brand span { color: var(--amber); margin-left: 5px; } -.balance { margin-left: auto; text-align: right; line-height: 1.1; } +.balance { margin-left: auto; text-align: right; line-height: 1.15; } .balance .label { - display: block; font-size: 9px; letter-spacing: 0.2em; - text-transform: uppercase; color: var(--muted); + display: block; font-size: 9px; letter-spacing: 0.18em; + text-transform: uppercase; color: var(--green-dim); } .balance .value { font-variant-numeric: tabular-nums; - font-size: 18px; font-weight: 700; color: var(--cyan); - text-shadow: 0 0 18px #38f2e455; + font-size: 17px; font-weight: 700; color: var(--amber); + text-shadow: 0 0 14px #ffb00066; } .sound { - background: none; border: 1px solid #ffffff20; color: var(--muted); - width: 34px; height: 34px; border-radius: 50%; font-size: 15px; + background: none; border: 1px solid var(--green-ghost); color: var(--green-dim); + width: 32px; height: 32px; border-radius: 3px; font-size: 14px; } -.sound.on { color: var(--cyan); border-color: var(--cyan); } +.sound.on { color: var(--green); border-color: var(--green); } /* ---------- panels ---------- */ -.panel { - max-width: 460px; margin: 0 auto; padding: 40px 22px; -} +.panel { max-width: 460px; margin: 0 auto; padding: 34px 20px; } .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; } +h1 { + font-size: 19px; margin: 0 0 10px; letter-spacing: 0.1em; + text-transform: uppercase; +} +h1::after { + content: "_"; + animation: blink 1.1s steps(2) infinite; + color: var(--green); +} +@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } -.muted { color: var(--muted); } -.small { font-size: 12.5px; } -.fineprint { font-size: 11px; color: var(--muted); margin-top: 18px; word-break: break-all; } +h2 { + font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--green-dim); margin: 0 0 10px; +} +h2::before { content: "// "; } + +.muted { color: var(--green-dim); } +.small { font-size: 12px; } +.fineprint { + font-size: 10.5px; color: var(--green-dim); margin-top: 16px; + 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 */ + width: 100%; padding: 11px 12px; margin: 7px 0; + background: #00140e; border: 1px solid var(--green-ghost); border-radius: 3px; + color: var(--green); font-size: 16px; /* 16px stops iOS zooming on focus */ + font-family: inherit; } -input:focus { outline: none; border-color: var(--cyan); } +input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 10px #00ff9c33; } +input::placeholder { color: var(--green-ghost); } button { - font: inherit; cursor: pointer; border-radius: 10px; - border: 1px solid #ffffff20; background: #ffffff0c; color: var(--ink); - padding: 11px 16px; + font: inherit; cursor: pointer; border-radius: 3px; + border: 1px solid var(--green-ghost); background: #00140e; color: var(--green); + padding: 10px 14px; letter-spacing: 0.06em; } 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; + background: #00291c; color: var(--green); + border: 1px solid var(--green); font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; + box-shadow: 0 0 18px #00ff9c33, inset 0 0 18px #00ff9c11; + width: 100%; padding: 14px; } -.primary.big { font-size: 17px; padding: 18px; } +.primary.big { font-size: 15px; padding: 17px; } + +/* Money at risk turns amber and pulses — the one element that demands a + decision looks different from everything else. */ .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; + background: #2a1c00; color: var(--amber); border-color: var(--amber); + box-shadow: 0 0 26px #ffb00055, inset 0 0 18px #ffb00011; + animation: urge 800ms ease-in-out infinite; } -.primary:disabled { opacity: 0.4; box-shadow: none; animation: none; } +.primary:disabled { opacity: 0.35; box-shadow: none; animation: none; } @keyframes urge { - 0%, 100% { box-shadow: 0 0 26px #ff3ec855; } - 50% { box-shadow: 0 0 40px #ff3ec8aa; } + 0%, 100% { box-shadow: 0 0 18px #ffb00044, inset 0 0 14px #ffb00011; } + 50% { box-shadow: 0 0 34px #ffb000aa, inset 0 0 22px #ffb00022; } } /* ---------- tabs ---------- */ .tabs { - display: flex; gap: 6px; padding: 12px 12px 0; + display: flex; gap: 4px; padding: 10px 10px 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; + flex: 1; padding: 9px 3px; font-size: 11px; letter-spacing: 0.1em; + text-transform: uppercase; background: none; border: none; + color: var(--green-dim); border-bottom: 1px solid var(--green-ghost); + border-radius: 0; +} +.tab.active { + color: var(--green); border-bottom-color: var(--green); + text-shadow: 0 0 10px #00ff9c88; } -.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); } -.view { max-width: 620px; margin: 0 auto; padding: 14px 14px 60px; } +.view { max-width: 620px; margin: 0 auto; padding: 12px 12px 60px; } /* ---------- crash stage ---------- */ -.gamepick { display: flex; gap: 6px; margin-bottom: 12px; } +.gamepick { display: flex; gap: 4px; margin-bottom: 10px; } .gamepick button { - flex: 1; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; - padding: 9px 4px; color: var(--muted); + flex: 1; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; + padding: 8px 3px; color: var(--green-dim); +} +.gamepick button.on { + color: var(--green); border-color: var(--green); background: #00291c; } -.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%); + position: relative; border-radius: 3px; overflow: hidden; + border: 1px solid var(--green-ghost); + background: #000603; aspect-ratio: 4 / 3; } #canvas { width: 100%; height: 100%; display: block; } @@ -156,100 +203,152 @@ button:active { transform: translateY(1px); } 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; + font-size: clamp(42px, 16vw, 82px); font-weight: 700; + font-variant-numeric: tabular-nums; letter-spacing: -0.01em; + color: var(--magenta); text-shadow: 0 0 30px #ff2e8877; } -.multiplier.crashed { color: var(--danger); text-shadow: 0 0 40px #ff4d6d66; } -.multiplier.won { color: var(--gold); text-shadow: 0 0 46px #ffc85777; } +.multiplier.crashed { + color: var(--red); text-shadow: 0 0 34px #ff335599; + animation: glitch 260ms steps(2) 3; +} +.multiplier.won { + color: var(--amber); text-shadow: 0 0 40px #ffb000aa; +} + +@keyframes glitch { + 0% { transform: translate(0, 0); } + 25% { transform: translate(-3px, 1px); } + 50% { transform: translate(3px, -1px); } + 75% { transform: translate(-2px, -2px); } + 100% { transform: translate(0, 0); } +} + .state { - font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase; - color: var(--muted); margin-top: 6px; + font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; + color: var(--green-dim); margin-top: 5px; } -.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; } +.controls { margin-top: 12px; } +.stakerow { display: flex; align-items: center; gap: 4px; margin-bottom: 8px; } +.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 11px 3px; font-size: 13px; } +.chip.on { border-color: var(--green); color: var(--green); background: #00291c; } +.unit { font-size: 10px; color: var(--green-dim); 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); } +/* Auto cash-out target row. */ +.autorow { + display: flex; align-items: center; gap: 8px; margin-bottom: 8px; + border: 1px solid var(--green-ghost); border-radius: 3px; padding: 8px 10px; +} +.autorow label { + font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--green-dim); white-space: nowrap; +} +.autorow input { + margin: 0; padding: 6px 8px; text-align: right; width: 90px; + font-variant-numeric: tabular-nums; +} +.autorow .x { color: var(--green-dim); font-size: 13px; } +.autorow.armed { border-color: var(--amber); } +.autorow.armed label, .autorow.armed .x { color: var(--amber); } +.autorow.armed input { color: var(--amber); border-color: var(--amber); } -.players { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; } +.hint { + min-height: 17px; margin-top: 7px; font-size: 11.5px; + color: var(--green-dim); text-align: center; +} +.hint.bad { color: var(--red); } +.hint.good { color: var(--amber); } + +.players { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; } .player { - display: flex; align-items: center; gap: 8px; padding: 8px 12px; - background: #ffffff06; border: 1px solid #ffffff10; border-radius: 9px; - font-size: 13px; + display: flex; align-items: center; gap: 8px; padding: 7px 10px; + background: #00120c; border: 1px solid var(--green-ghost); border-radius: 3px; + font-size: 12.5px; } .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); } +.player .amt { font-variant-numeric: tabular-nums; color: var(--green-dim); } +.player.out { border-color: #4a3300; } +.player.out .amt { color: var(--amber); } -.proof { margin-top: 18px; } +.proof { margin-top: 16px; } .proof summary { - cursor: pointer; font-size: 11px; letter-spacing: 0.16em; - text-transform: uppercase; color: var(--muted); padding: 8px 0; + cursor: pointer; font-size: 10px; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--green-dim); padding: 7px 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; } +.kv { display: flex; gap: 8px; font-size: 10.5px; padding: 3px 0; } +.kv span { color: var(--green-dim); min-width: 92px; } +.kv code { word-break: break-all; color: var(--green); opacity: 0.75; } -/* ---------- scratch ---------- */ +/* ---------- cards / scratch ---------- */ .card { - background: #ffffff07; border: 1px solid #ffffff14; - border-radius: 14px; padding: 16px; margin-bottom: 14px; + background: #00120c; border: 1px solid var(--green-ghost); + border-radius: 3px; padding: 14px; margin-bottom: 12px; +} +.card h3 { + margin: 0 0 4px; font-size: 15px; letter-spacing: 0.1em; + text-transform: uppercase; color: var(--green); } -.card h3 { margin: 0 0 4px; font-size: 18px; } -.grid { - display: grid; gap: 8px; margin: 14px 0; -} +.grid { display: grid; gap: 6px; margin: 12px 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; + font-size: 24px; border-radius: 3px; + background: #001f16; border: 1px solid var(--green-ghost); + color: var(--green-dim); + transition: background 200ms ease, color 200ms ease; +} +.cell.revealed { background: #000603; color: var(--green); border-color: var(--green-ghost); } +.cell.hit { + border-color: var(--amber); color: var(--amber); + box-shadow: 0 0 16px #ffb00055; } -.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; } +.odds { width: 100%; border-collapse: collapse; font-size: 11.5px; margin-top: 8px; } +.odds th, .odds td { + text-align: left; padding: 5px 3px; border-bottom: 1px solid var(--green-ghost); +} +.odds th { + color: var(--green-dim); font-weight: 400; font-size: 9.5px; + letter-spacing: 0.14em; text-transform: uppercase; +} +.odds td:last-child, .odds th:last-child { + text-align: right; font-variant-numeric: tabular-nums; +} +.rtp { color: var(--amber); font-weight: 700; } -.result { text-align: center; padding: 10px 0; font-size: 16px; } -.result.win { color: var(--gold); font-weight: 700; } +.result { text-align: center; padding: 8px 0; font-size: 14px; } +.result.win { + color: var(--amber); font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; +} /* ---------- wallet ---------- */ .pubkey { - display: block; word-break: break-all; font-size: 11px; - background: #00000055; padding: 10px; border-radius: 8px; margin: 8px 0; - color: var(--muted); + display: block; word-break: break-all; font-size: 10.5px; + background: #000603; padding: 9px; border-radius: 3px; margin: 7px 0; + color: var(--green-dim); border: 1px solid var(--green-ghost); } -.history { display: flex; flex-direction: column; gap: 3px; } +.history { display: flex; flex-direction: column; gap: 2px; } .entry { - display: flex; gap: 10px; font-size: 12px; padding: 8px 10px; - background: #ffffff05; border-radius: 8px; + display: flex; gap: 8px; font-size: 11.5px; padding: 7px 9px; + background: #000603; border-radius: 3px; } -.entry .kind { flex: 1; color: var(--muted); } +.entry .kind { flex: 1; color: var(--green-dim); } .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; } +.entry .delta.pos { color: var(--amber); } +.entry .delta.neg { color: var(--green-dim); } +.entry .after { + font-variant-numeric: tabular-nums; color: var(--green-dim); + min-width: 70px; 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 { margin-top: 10px; font-size: 11.5px; } +.verify-out .ok { color: var(--green); font-weight: 700; } +.verify-out .bad { color: var(--red); font-weight: 700; } .verify-out .kv code { font-size: 10px; } diff --git a/coverage.out b/coverage.out index ac10920..ac66dae 100644 --- a/coverage.out +++ b/coverage.out @@ -2,18 +2,41 @@ mode: set github.com/drjones/quantum-arcade/pkg/fair/fair.go:37.33,39.45 2 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:39.45,42.63 1 0 github.com/drjones/quantum-arcade/pkg/fair/fair.go:44.2,44.10 1 1 -github.com/drjones/quantum-arcade/pkg/fair/fair.go:48.49,48.76 1 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:48.49,48.76 1 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:51.38,51.52 1 1 -github.com/drjones/quantum-arcade/pkg/fair/fair.go:54.34,54.71 1 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:54.34,54.71 1 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:57.43,57.75 1 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:62.66,65.2 2 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:70.44,72.29 2 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:72.29,79.3 4 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:80.2,82.12 3 1 github.com/drjones/quantum-arcade/pkg/fair/fair.go:88.75,97.2 8 1 -github.com/drjones/quantum-arcade/pkg/fair/fair.go:110.74,115.29 4 0 -github.com/drjones/quantum-arcade/pkg/fair/fair.go:115.29,117.3 1 0 -github.com/drjones/quantum-arcade/pkg/fair/fair.go:118.2,126.3 2 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:110.74,115.29 4 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:115.29,117.3 1 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:118.2,126.3 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:41.40,46.2 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:49.69,51.16 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:51.16,53.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:54.2,55.43 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:55.43,56.67 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:59.2,66.38 5 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:71.70,73.16 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:73.16,75.3 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:76.2,77.53 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:77.53,79.3 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:81.2,84.8 4 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:84.8,86.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:87.2,90.33 3 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:90.33,92.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.2,93.42 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.42,95.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:96.2,96.12 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:100.39,102.33 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:102.33,103.27 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:103.27,105.4 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:110.58,112.51 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:112.51,114.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:115.2,115.34 1 1 github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:34.25,35.30 1 1 github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:35.30,36.87 1 1 github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:38.2,38.25 1 1 @@ -55,29 +78,6 @@ github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:129.2,129.10 1 1 github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:132.27,134.17 2 1 github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:134.17,136.3 1 1 github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:137.2,137.10 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:41.40,46.2 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:49.69,51.16 2 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:51.16,53.3 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:54.2,55.43 2 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:55.43,56.67 1 0 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:59.2,66.38 5 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:71.70,73.16 2 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:73.16,75.3 1 0 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:76.2,77.53 2 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:77.53,79.3 1 0 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:81.2,84.8 4 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:84.8,86.3 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:87.2,90.33 3 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:90.33,92.3 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.2,93.42 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.42,95.3 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:96.2,96.12 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:100.39,102.33 2 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:102.33,103.27 1 0 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:103.27,105.4 1 0 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:110.58,112.51 2 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:112.51,114.3 1 1 -github.com/drjones/quantum-arcade/pkg/identity/identity.go:115.2,115.34 1 1 github.com/drjones/quantum-arcade/pkg/sim/crash.go:25.30,25.69 1 1 github.com/drjones/quantum-arcade/pkg/sim/crash.go:36.40,59.24 7 1 github.com/drjones/quantum-arcade/pkg/sim/crash.go:59.24,61.3 1 0 @@ -109,6 +109,44 @@ github.com/drjones/quantum-arcade/pkg/sim/rng.go:47.35,53.2 5 1 github.com/drjones/quantum-arcade/pkg/sim/rng.go:56.31,67.2 10 1 github.com/drjones/quantum-arcade/pkg/sim/rng.go:69.36,69.73 1 1 github.com/drjones/quantum-arcade/pkg/sim/rng.go:73.30,75.2 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:66.34,68.31 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:68.31,70.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.2,71.24 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.24,74.3 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:75.2,75.12 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:79.34,81.31 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:81.31,88.22 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:88.22,90.4 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:91.3,91.27 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:93.2,93.13 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:98.41,100.31 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:100.31,102.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:103.2,103.28 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:107.62,114.31 4 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:114.31,116.24 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:116.24,118.9 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:122.2,129.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:142.60,144.23 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:144.23,146.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:147.2,152.38 3 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:152.38,155.26 3 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:155.26,158.4 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.2,162.23 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.23,163.21 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:163.21,164.12 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:166.3,167.57 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:167.57,168.43 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:168.43,170.13 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.4,172.38 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.38,174.13 2 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:176.4,176.9 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:178.3,179.17 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:181.2,181.14 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:221.37,222.28 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:222.28,223.17 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:223.17,225.4 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:227.2,227.24 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:233.122,237.2 3 1 github.com/drjones/quantum-arcade/pkg/room/room.go:103.67,113.2 1 1 github.com/drjones/quantum-arcade/pkg/room/room.go:118.54,124.20 5 1 github.com/drjones/quantum-arcade/pkg/room/room.go:124.20,129.3 4 1 @@ -189,44 +227,6 @@ github.com/drjones/quantum-arcade/pkg/room/room.go:427.3,427.31 1 1 github.com/drjones/quantum-arcade/pkg/room/room.go:430.2,441.50 2 1 github.com/drjones/quantum-arcade/pkg/room/room.go:441.50,444.3 2 1 github.com/drjones/quantum-arcade/pkg/room/room.go:445.2,445.10 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:66.34,68.31 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:68.31,70.3 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.2,71.24 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.24,74.3 1 0 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:75.2,75.12 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:79.34,81.31 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:81.31,88.22 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:88.22,90.4 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:91.3,91.27 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:93.2,93.13 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:98.41,100.31 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:100.31,102.3 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:103.2,103.28 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:107.62,114.31 4 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:114.31,116.24 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:116.24,118.9 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:122.2,129.3 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:142.60,144.23 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:144.23,146.3 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:147.2,152.38 3 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:152.38,155.26 3 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:155.26,158.4 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.2,162.23 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.23,163.21 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:163.21,164.12 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:166.3,167.57 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:167.57,168.43 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:168.43,170.13 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.4,172.38 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.38,174.13 2 0 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:176.4,176.9 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:178.3,179.17 2 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:181.2,181.14 1 1 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:221.37,222.28 1 0 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:222.28,223.17 1 0 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:223.17,225.4 1 0 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:227.2,227.24 1 0 -github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:233.122,237.2 3 1 github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:50.38,50.68 1 1 github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:57.108,58.24 1 1 github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:58.24,60.3 1 0 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..2f4f4cf --- /dev/null +++ b/docs/API.md @@ -0,0 +1,287 @@ +# Quantum Arcade API + +Everything the browser client does, it does over this API. There is no private +back channel — you can write a bot that plays exactly as well as a human, and +nothing in the protocol is reserved for the official client. + +Base URL is wherever the server is listening, e.g. `http://arcade.lan:8080`. +All request and response bodies are JSON. Amounts are **millisatoshis** +(`1 sat = 1000 msat`) and always integers. + +## Authentication + +Identity is an ed25519 keypair. You prove ownership by signing a server-issued +challenge; there is no password and nothing to register. + +### 1. Request a challenge + +``` +POST /api/auth/challenge +{ "pubkey": "<64 hex chars>" } + +→ { "challenge": "<64 hex chars>" } +``` + +The challenge is single-use and expires after two minutes. + +### 2. Sign it and verify + +Sign the **raw 32 bytes** of the challenge (decode the hex first — do not sign +the hex string). + +``` +POST /api/auth/verify +{ "pubkey": "", "signature": "<128 hex chars>", "nickname": "botto" } + +→ { "token": "", "balance_msat": 0 } +``` + +Pass the token on every subsequent request: + +``` +Authorization: Bearer +``` + +Tokens live in memory, so a server restart signs everyone out. Just +re-authenticate — it costs two requests and no human interaction. + +## Balance and history + +``` +GET /api/balance +→ { "balance_msat": 4500000 } +``` + +``` +GET /api/history +→ { "entries": [ + { "Kind": "payout", "AmountMsat": 2500000, + "BalanceBefore": 2000000, "BalanceAfter": 4500000, + "RoundID": 412, "CreatedAt": "..." } + ] } +``` + +Every balance change has exactly one entry explaining it. Nothing moves without +a record. + +## Peer-to-peer transfers + +``` +POST /api/transfer +{ "to_pubkey": "", "amount_msat": 100000 } + +→ { "balance_msat": 4400000 } +``` + +Instant and internal. Fails with 400 if you cannot cover it. + +## Crash games + +Three rooms share one engine: `rocket`, `orbital`, `tower`. + +### Watch the state + +``` +GET /api/games +→ { "rooms": [ { + "round_id": 412, + "game": "rocket", + "state": "betting_open", // betting_open | locked | running | settled + "tick": 0, + "multiplier": "1.000000", + "commitment": "", // published before betting opens + "server_seed": "", // present only once settled + "crash_point": "3.472190", // present only once settled + "players": [ { "nickname": "botto", "pubkey": "", + "stake_msat": 100000, "cashed_out": "2.500000", + "auto": true, "payout_msat": 250000 } ], + "next_phase_in_seconds": 12.4 + } ] } +``` + +For a live feed instead of polling, open a WebSocket to `/ws/{game}` and you +will receive the same object on every tick. + +### Place a bet + +Only during `betting_open`, once per round. + +``` +POST /api/bet +{ "game": "rocket", + "stake_msat": 100000, + "nickname": "botto", + "auto_cashout": 2.5 } // optional; omit or 0 for no target + +→ { "balance_msat": 4300000 } +``` + +The stake leaves your balance immediately. `auto_cashout` must be above 1.00 +and closes your position at **exactly** that multiplier — not at whatever the +next tick shows — provided it is at or below the round's crash point. + +Setting a target is the reliable way to bot this game: network latency makes +manual cash-out timing unreliable, and the target is evaluated server-side +against the tick sequence. + +### Cash out manually + +Only during `running`. + +``` +POST /api/cashout +{ "game": "rocket" } + +→ { "cashed_out_at": "2.317445" } +``` + +Payment lands at settlement, a moment later. + +## Scratch tickets + +``` +GET /api/scratch/catalog +→ { "tickets": [ { + "id": "nebula-nine", "name": "Nebula Nine", "cells": 9, + "rtp_bp": 9900, + "odds": [ { "tier": "Double", "payout_bp": 20000, + "weight": 155000, "denominator": 1000000, + "one_in": 6 } ] + } ] } +``` + +The odds table is generated from the same data that produces outcomes, so it +cannot drift from reality. `rtp_bp` is in basis points: 9900 is 99%. + +``` +POST /api/scratch/play +{ "ticket_id": "nebula-nine", "stake_msat": 10000 } + +→ { "outcome": { "tier_name": "Double", "payout_bp": 20000, + "payout_msat": 20000, "roll": 481203, + "cells": [2,5,2,0,2,4,1,3,5] }, + "proof": { "commitment": "...", "server_seed": "...", + "participants": ["..."], "nonce": 91, + "round_seed": "..." }, + "balance_msat": 4310000 } +``` + +Resolves immediately. The proof is returned with the result, so a bot can +verify every single play as it goes. + +## Verification + +``` +GET /api/verify/{roundID} +→ { "round_id": 412, "game": "rocket", "nonce": 412, + "commitment": "", "server_seed": "", + "client_seed": "", "crash_point": 14914127396, + "participants": ["", ""] } +``` + +Returns 409 while a round is still open — the seed stays sealed until +settlement, otherwise you could compute the outcome before betting closed. + +To check it yourself: + +1. `SHA256(server_seed)` must equal `commitment`. +2. `client_seed` must equal `SHA256(` each participant pubkey, each prefixed by + its 4-byte big-endian length, concatenated in join order `)`. +3. The round seed is `HMAC-SHA256(server_seed, client_seed || uint64be(nonce))`. +4. `crash_point` is derived from that seed. It is Q32.32 fixed-point: divide by + 2³² to get the multiplier. + +## Health + +``` +GET /api/health +→ { "status": "ok", "ledger_sum_msat": 0 } +``` + +`ledger_sum_msat` sums every account. Because each transaction balances to +zero, it must always be zero. Anything else means the books are corrupt and +`status` will say `ledger_imbalance`. + +## Errors + +Failures return the appropriate status with `{ "error": "..." }`. Common cases: + +| Status | Meaning | +|---|---| +| 400 | Bad request, insufficient funds, betting closed, already in this round | +| 401 | Missing or unknown token | +| 404 | No such game or ticket | +| 409 | Round has not settled; the seed is still sealed | + +## A complete bot + +Plays every rocket round with a 2× target and verifies each result. + +```python +import time, requests +from nacl.signing import SigningKey # pip install pynacl + +BASE = "http://arcade.lan:8080" +key = SigningKey.generate() # persist this to keep your balance +pub = key.verify_key.encode().hex() + +chal = requests.post(f"{BASE}/api/auth/challenge", json={"pubkey": pub}).json() +sig = key.sign(bytes.fromhex(chal["challenge"])).signature.hex() +tok = requests.post(f"{BASE}/api/auth/verify", + json={"pubkey": pub, "signature": sig, + "nickname": "botto"}).json()["token"] +S = requests.Session() +S.headers["Authorization"] = f"Bearer {tok}" + +seen = None +while True: + room = next(r for r in S.get(f"{BASE}/api/games").json()["rooms"] + if r["game"] == "rocket") + + if room["state"] == "betting_open" and room["round_id"] != seen: + r = S.post(f"{BASE}/api/bet", json={ + "game": "rocket", "stake_msat": 10_000, + "auto_cashout": 2.0, "nickname": "botto"}) + if r.ok: + seen = room["round_id"] + print(f"round {seen}: in, balance {r.json()['balance_msat']}") + + if room["state"] == "settled" and room.get("server_seed"): + print(f" crashed at {room['crash_point']}") + + time.sleep(1) +``` + +Verifying a settled round, using only published values: + +```python +import hashlib, hmac, struct + +def verify(round_id): + r = requests.get(f"{BASE}/api/verify/{round_id}").json() + seed = bytes.fromhex(r["server_seed"]) + + assert hashlib.sha256(seed).hexdigest() == r["commitment"], "bad commitment" + + h = hashlib.sha256() + for p in r["participants"]: + pk = bytes.fromhex(p) + h.update(struct.pack(">I", len(pk)) + pk) + assert h.hexdigest() == r["client_seed"], "bad client seed" + + round_seed = hmac.new(seed, + bytes.fromhex(r["client_seed"]) + + struct.pack(">Q", r["nonce"]), + hashlib.sha256).digest() + print("verified:", round_seed.hex(), + "crash", r["crash_point"] / 2**32) +``` + +## Rate and fairness notes + +There is no rate limiting, because this runs on a private network among people +who know each other. If you point it at a hostile network, add some. + +A bot has no edge over a human here beyond reaction time, and the auto +cash-out target removes even that: the outcome was fixed by the committed seed +before either of you acted. diff --git a/pkg/room/room.go b/pkg/room/room.go index 469c2da..7bdb64b 100644 --- a/pkg/room/room.go +++ b/pkg/room/room.go @@ -50,6 +50,12 @@ type Bet struct { StakeMsat int64 CashedOutAt fixed.F // zero until they cash out PayoutMsat int64 + + // AutoCashOutAt is an optional target set before the round starts. When + // the multiplier reaches it the position closes automatically at exactly + // that value — not at whatever the next tick happens to show — so the + // player gets the number they chose. Zero means no target. + AutoCashOutAt fixed.F } // Snapshot is what clients render. It carries the seed inputs so a client can @@ -75,6 +81,8 @@ type Player struct { StakeMsat int64 `json:"stake_msat"` CashedOut string `json:"cashed_out,omitempty"` PayoutMsat int64 `json:"payout_msat"` + // Auto is true when the position closed on its own target rather than a tap. + Auto bool `json:"auto,omitempty"` } // Room runs one game's round loop. @@ -188,6 +196,10 @@ func (r *Room) step(ctx context.Context) error { r.mu.Lock() r.tick++ reached := sim.MultiplierAt(r.tick) + // Close any positions whose target has been met. This happens before + // the crash check so a target at or below the crash point always pays, + // regardless of where tick boundaries happen to fall. + r.triggerAutoCashOutsLocked(reached) // 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 @@ -321,12 +333,35 @@ func (r *Room) settle(ctx context.Context) error { return nil } +// triggerAutoCashOutsLocked closes positions whose target the multiplier has +// reached. Callers must hold the lock. +// +// A target above the crash point never fires: the round is already over at +// that value. A target at or below it always fires, at exactly the target. +func (r *Room) triggerAutoCashOutsLocked(reached fixed.F) { + for _, b := range r.bets { + if b.CashedOutAt != 0 || b.AutoCashOutAt == 0 { + continue + } + if b.AutoCashOutAt > r.crashPoint { + continue // the round ends before this target is reached + } + if reached >= b.AutoCashOutAt { + b.CashedOutAt = b.AutoCashOutAt + } + } +} + // 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 { +func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64, autoCashOutAt fixed.F) error { if stakeMsat <= 0 { return ledger.ErrNonPositiveAmount } + // A target at or below 1.0 would close instantly for no gain. + if autoCashOutAt != 0 && autoCashOutAt <= fixed.One { + return fmt.Errorf("auto cash-out target must be above 1.00") + } r.mu.Lock() if r.state != StateBetting { @@ -367,6 +402,7 @@ func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nic r.bets[accountID] = &Bet{ AccountID: accountID, Pubkey: pubkey, Nickname: nickname, StakeMsat: stakeMsat, + AutoCashOutAt: autoCashOutAt, } r.order = append(r.order, pubkey) r.mu.Unlock() @@ -423,6 +459,7 @@ func (r *Room) Snapshot() Snapshot { } if b.CashedOutAt != 0 { p.CashedOut = b.CashedOutAt.String() + p.Auto = b.AutoCashOutAt != 0 && b.CashedOutAt == b.AutoCashOutAt } players = append(players, p) } diff --git a/pkg/room/room_test.go b/pkg/room/room_test.go index 30c9a26..eb6394d 100644 --- a/pkg/room/room_test.go +++ b/pkg/room/room_test.go @@ -190,7 +190,7 @@ func TestPlaceBetDebitsStakeImmediately(t *testing.T) { f.openBetting() before, _ := f.ledger.Balance(f.ctx, id) - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 3_000); err != nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 3_000, 0); err != nil { t.Fatal(err) } after, _ := f.ledger.Balance(f.ctx, id) @@ -204,13 +204,13 @@ func TestCannotBetOutsideBettingWindow(t *testing.T) { id, pk := f.player("a", 10_000) // Room starts settled. - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil { t.Fatal("bet accepted while settled") } f.openBetting() f.startRun() - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil { t.Fatal("bet accepted while running") } } @@ -220,10 +220,10 @@ func TestCannotBetTwiceInOneRound(t *testing.T) { id, pk := f.player("a", 10_000) f.openBetting() - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil { t.Fatal(err) } - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil { t.Fatal("second bet in the same round was accepted") } } @@ -233,7 +233,7 @@ func TestCannotBetMoreThanBalance(t *testing.T) { id, pk := f.player("a", 1_000) f.openBetting() - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 50_000); err == nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 50_000, 0); err == nil { t.Fatal("bet larger than balance was accepted") } // And nothing was taken. @@ -248,7 +248,7 @@ func TestNonPositiveStakesRejected(t *testing.T) { f.openBetting() for _, stake := range []int64{0, -1, -5_000} { - if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake); err == nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err == nil { t.Fatalf("stake %d was accepted", stake) } } @@ -260,7 +260,7 @@ func TestCashOutOnlyWhileRunning(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil { t.Fatal(err) } @@ -278,7 +278,7 @@ func TestCannotCashOutTwice(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() - _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000) + _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0) f.startRun() f.forceCrashPoint(100) @@ -307,7 +307,7 @@ func TestCannotCashOutAfterTheCrash(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() - _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000) + _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0) f.startRun() // Jump past the crash point without letting the loop settle. @@ -338,7 +338,7 @@ func TestCashedOutPlayerIsPaid(t *testing.T) { f.openBetting() const stake = 10_000 - if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake); err != nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil { t.Fatal(err) } f.startRun() @@ -376,7 +376,7 @@ func TestPlayerWhoDidNotCashOutGetsNothing(t *testing.T) { id, pk := f.player("a", 100_000) f.openBetting() - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000); err != nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil { t.Fatal(err) } f.startRun() @@ -404,7 +404,7 @@ func TestBooksBalanceAcrossAFullRound(t *testing.T) { var ids []int64 for i := 0; i < 5; i++ { id, pk := f.player(fmt.Sprintf("p%d", i), 100_000) - if err := f.room.PlaceBet(f.ctx, id, pk, "p", 10_000); err != nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "p", 10_000, 0); err != nil { t.Fatal(err) } ids = append(ids, id) @@ -447,7 +447,7 @@ func TestCrashPointDerivesFromCommittedSeedAndPlayers(t *testing.T) { id, pk := f.player("a", 100_000) f.openBetting() - if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil { t.Fatal(err) } f.startRun() @@ -560,7 +560,7 @@ func TestConcurrentBetsAreAllRecorded(t *testing.T) { wg.Add(1) go func(i int, a acct) { defer wg.Done() - errs[i] = f.room.PlaceBet(f.ctx, a.id, a.pk, "c", 5_000) + errs[i] = f.room.PlaceBet(f.ctx, a.id, a.pk, "c", 5_000, 0) }(i, a) } wg.Wait() @@ -586,7 +586,7 @@ func TestConcurrentCashOutsYieldOne(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() - _ = f.room.PlaceBet(f.ctx, id, pk, "a", 5_000) + _ = f.room.PlaceBet(f.ctx, id, pk, "a", 5_000, 0) f.startRun() f.forceCrashPoint(100) @@ -619,7 +619,7 @@ func TestSnapshotReportsCashOutMultiplier(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() - _ = f.room.PlaceBet(f.ctx, id, pk, "nick", 5_000) + _ = f.room.PlaceBet(f.ctx, id, pk, "nick", 5_000, 0) f.startRun() f.forceCrashPoint(100) @@ -645,3 +645,167 @@ func TestMultiplierStartsAtOneEachRound(t *testing.T) { t.Fatalf("multiplier at round open = %s, want %s", got, fixed.One.String()) } } + +/* ---------------- auto cash-out ---------------- */ + +func TestAutoCashOutFiresAtExactlyTheTarget(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + target := fixed.FromInt(3) + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(100) // well above the target, so it must fire + + // Advance to the tick that reaches the target. + f.advanceTo(sim.TicksToMultiplier(target)) + f.room.mu.Lock() + f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) + got := f.room.bets[id].CashedOutAt + f.room.mu.Unlock() + + if got != target { + t.Fatalf("auto cash-out closed at %v, want exactly %v", got, target) + } +} + +func TestAutoCashOutDoesNotFireBelowTarget(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + target := fixed.FromInt(5) + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(100) + + // One tick short of the target. + f.advanceTo(sim.TicksToMultiplier(target) - 1) + f.room.mu.Lock() + f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) + got := f.room.bets[id].CashedOutAt + f.room.mu.Unlock() + + if got != 0 { + t.Fatalf("auto cash-out fired early at %v", got) + } +} + +// A target above the crash point must never pay: the round ends first. +func TestAutoCashOutAboveCrashPointNeverFires(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, fixed.FromInt(50)); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(3) // crashes well before the target + + f.advanceTo(sim.RoundTicks - 1) + f.room.mu.Lock() + f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) + got := f.room.bets[id].CashedOutAt + f.room.mu.Unlock() + + if got != 0 { + t.Fatalf("auto cash-out paid %v on a target above the crash point", got) + } +} + +// A target exactly at the crash point is a win, not a loss. +func TestAutoCashOutAtExactlyTheCrashPointPays(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + target := fixed.FromInt(4) + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(4) + + f.advanceTo(sim.TicksToMultiplier(target)) + f.room.mu.Lock() + f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) + got := f.room.bets[id].CashedOutAt + f.room.mu.Unlock() + + if got != target { + t.Fatalf("target equal to the crash point paid %v, want %v", got, target) + } +} + +func TestAutoCashOutTargetMustExceedOne(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + + for _, target := range []fixed.F{fixed.One, fixed.One / 2} { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, target); err == nil { + t.Fatalf("target %v was accepted", target) + } + } + // And the stake was never taken. + if bal, _ := f.ledger.Balance(f.ctx, id); bal != 100_000 { + t.Fatalf("balance = %d after rejected bets, want 100000", bal) + } +} + +// An auto cash-out must pay the target exactly, not the tick's multiplier. +func TestAutoCashOutPaysTheTargetExactly(t *testing.T) { + f := newFixture(t) + house, _ := f.ledger.AccountByName(f.ctx, "house_pot") + hp, _ := f.player("housefund", 5_000_000) + if _, err := f.ledger.Transfer(f.ctx, hp, house, 5_000_000); err != nil { + t.Fatal(err) + } + id, pk := f.player("a", 100_000) + + f.openBetting() + const stake = 10_000 + target := fixed.FromInt(3) + if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, target); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(100) + + f.advanceTo(sim.TicksToMultiplier(target)) + f.room.mu.Lock() + f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) + f.room.mu.Unlock() + + before, _ := f.ledger.Balance(f.ctx, id) + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + after, _ := f.ledger.Balance(f.ctx, id) + + if got, want := after-before, int64(stake*3); got != want { + t.Fatalf("paid %d, want exactly %d (3.00x of %d)", got, want, stake) + } +} + +// A manual cash-out still works when an auto target is set but not yet reached. +func TestManualCashOutOverridesAPendingTarget(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, fixed.FromInt(50)); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(100) + + at, err := f.room.CashOut(id) + if err != nil { + t.Fatalf("manual cash-out rejected: %v", err) + } + if at >= fixed.FromInt(50) { + t.Fatalf("manual cash-out returned %v, expected the current multiplier", at) + } +} diff --git a/pkg/scratch/scratch.go b/pkg/scratch/scratch.go index 68876d5..c8488b0 100644 --- a/pkg/scratch/scratch.go +++ b/pkg/scratch/scratch.go @@ -189,11 +189,11 @@ var Catalog = []Ticket{ Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.", Cells: 9, // Weights are out of 1,000,000 and sum to it exactly. The weighted - // payout sums to 9.8e9, which is an RTP of exactly 98%. + // payout sums to 9.9e9, which is an RTP of exactly 99%. Tiers: []Tier{ - {Name: "No win", Weight: 463_400, PayoutBP: 0}, + {Name: "No win", Weight: 458_400, PayoutBP: 0}, {Name: "Stake back", Weight: 350_000, PayoutBP: 10_000}, - {Name: "Double", Weight: 150_000, PayoutBP: 20_000}, + {Name: "Double", Weight: 155_000, PayoutBP: 20_000}, {Name: "Five times", Weight: 30_000, PayoutBP: 50_000}, {Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000}, {Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000}, @@ -204,13 +204,13 @@ var Catalog = []Ticket{ Name: "Singularity", Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.", Cells: 6, - // Same 98% RTP as Nebula Nine, but concentrated in the rare tiers: + // Same 99% RTP as Nebula Nine, but concentrated in the rare tiers: // you lose far more often, and the top prize is 500x. Tiers: []Tier{ - {Name: "No win", Weight: 866_530, PayoutBP: 0}, + {Name: "No win", Weight: 865_530, PayoutBP: 0}, {Name: "Stake back", Weight: 60_000, PayoutBP: 10_000}, {Name: "Triple", Weight: 45_000, PayoutBP: 30_000}, - {Name: "Ten times", Weight: 25_000, PayoutBP: 100_000}, + {Name: "Ten times", Weight: 26_000, PayoutBP: 100_000}, {Name: "Hundred times", Weight: 3_000, PayoutBP: 1_000_000}, {Name: "Singularity", Weight: 470, PayoutBP: 5_000_000}, }, diff --git a/pkg/scratch/scratch_test.go b/pkg/scratch/scratch_test.go index d334f57..9f63152 100644 --- a/pkg/scratch/scratch_test.go +++ b/pkg/scratch/scratch_test.go @@ -20,8 +20,8 @@ func TestCatalogIsCoherent(t *testing.T) { func TestPublishedRTPIsHonest(t *testing.T) { for _, ticket := range scratch.Catalog { rtp := ticket.RTPBasisPoints() - if rtp != 9800 { - t.Errorf("%s: RTP = %d bp, want 9800", ticket.ID, rtp) + if rtp != 9900 { + t.Errorf("%s: RTP = %d bp, want 9900", ticket.ID, rtp) } } } diff --git a/pkg/sim/crash.go b/pkg/sim/crash.go index 4abf2fc..6152264 100644 --- a/pkg/sim/crash.go +++ b/pkg/sim/crash.go @@ -6,8 +6,13 @@ import ( "github.com/drjones/quantum-arcade/pkg/fixed" ) -// HouseEdgeBP is the house edge in basis points (200 = 2.00%). -const HouseEdgeBP int64 = 200 +// HouseEdgeBP is the house edge in basis points (100 = 1.00%). +// +// One percent is deliberately generous — better than almost anything +// commercial. This is a game among friends, not a revenue stream, and a +// thinner edge means the pot lasts the whole night instead of draining +// toward the house. +const HouseEdgeBP int64 = 100 // TickHz is the simulation rate. Rounds advance in whole ticks only. const TickHz = 60 diff --git a/pkg/sim/crash_test.go b/pkg/sim/crash_test.go index d525647..592653a 100644 --- a/pkg/sim/crash_test.go +++ b/pkg/sim/crash_test.go @@ -41,8 +41,8 @@ func TestHouseEdgeAtTwoX(t *testing.T) { } } pct := float64(wins) * 100 / n - if pct < 47.5 || pct > 50.5 { - t.Fatalf("win rate at 2.00x = %.2f%%, want ~49%%", pct) + if pct < 48.5 || pct > 51.0 { + t.Fatalf("win rate at 2.00x = %.2f%%, want ~49.5%%", pct) } } @@ -60,8 +60,8 @@ func TestExpectedReturnMatchesEdge(t *testing.T) { } } rtp := returned * 100 / n - if rtp < 96.0 || rtp > 100.0 { - t.Fatalf("RTP at %dx = %.2f%%, want ~98%%", targetX, rtp) + if rtp < 97.0 || rtp > 101.0 { + t.Fatalf("RTP at %dx = %.2f%%, want ~99%%", targetX, rtp) } } }