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 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -37,13 +35,13 @@
@@ -76,6 +74,13 @@
100
sats
+
+ auto out
+
+ ×
+
+
Place bet
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)
}
}
}