feat: auto cash-out targets, 1% house edge, terminal aesthetic
Auto cash-out closes a position at exactly the chosen target rather than the next tick's multiplier, and fires whenever the target is at or below the crash point. This is the feature that makes the game playable over a network, where manual timing is at the mercy of latency. House edge drops from 2% to 1% across crash and scratch. Scratch prize tables retuned so the published 99% RTP is exact. Adds docs/API.md: the client uses no private endpoints, so anyone can write a bot against the same API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
30
README.md
30
README.md
@@ -7,10 +7,18 @@ outcomes any player can verify on their own phone.
|
|||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
Three shared crash games — a rocket fighting gravity, a decaying orbit, and a
|
Three shared crash games — a rocket fighting gravity, a decaying orbit, and a
|
||||||
stacking tower — running on a five-minute heartbeat, plus instant scratch
|
stacking tower — plus instant scratch tickets to play between rounds. Set an
|
||||||
tickets to play between rounds. Identity is a keypair your browser generates;
|
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.
|
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,
|
Everything runs on one machine: one Go binary with the client embedded,
|
||||||
PostgreSQL, and Redis.
|
PostgreSQL, and Redis.
|
||||||
|
|
||||||
@@ -77,7 +85,7 @@ One binary, with enforced internal boundaries:
|
|||||||
| `pkg/ledger` | Append-only double-entry accounting |
|
| `pkg/ledger` | Append-only double-entry accounting |
|
||||||
| `pkg/scratch` | Scratch tickets and their published odds |
|
| `pkg/scratch` | Scratch tickets and their published odds |
|
||||||
| `pkg/identity` | Keypair sign-in via signed challenge |
|
| `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
|
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
|
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
|
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.
|
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
|
## Status
|
||||||
|
|
||||||
Built and tested:
|
Built and tested:
|
||||||
@@ -109,7 +131,9 @@ Built and tested:
|
|||||||
- three crash games with live multiplayer rounds
|
- three crash games with live multiplayer rounds
|
||||||
- two scratch tickets with verified-honest odds
|
- two scratch tickets with verified-honest odds
|
||||||
- keypair identity, peer-to-peer transfers, transaction history
|
- keypair identity, peer-to-peer transfers, transaction history
|
||||||
|
- auto cash-out targets that pay your exact number
|
||||||
- in-browser verifier
|
- in-browser verifier
|
||||||
|
- a documented public API, good enough to write bots against
|
||||||
|
|
||||||
Not yet built:
|
Not yet built:
|
||||||
|
|
||||||
|
|||||||
@@ -278,3 +278,67 @@ func itoa(v int64) string {
|
|||||||
}
|
}
|
||||||
return string(buf[i:])
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/coder/websocket/wsjson"
|
"github.com/coder/websocket/wsjson"
|
||||||
"github.com/drjones/quantum-arcade/pkg/fair"
|
"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/identity"
|
||||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
"github.com/drjones/quantum-arcade/pkg/room"
|
"github.com/drjones/quantum-arcade/pkg/room"
|
||||||
@@ -31,6 +32,10 @@ import (
|
|||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFiles embed.FS
|
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
|
// Games offered as shared rounds. They share one engine and differ in how the
|
||||||
// client renders the climb.
|
// client renders the climb.
|
||||||
var games = []string{"rocket", "orbital", "tower"}
|
var games = []string{"rocket", "orbital", "tower"}
|
||||||
@@ -361,6 +366,9 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
|
|||||||
Game string `json:"game"`
|
Game string `json:"game"`
|
||||||
StakeMsat int64 `json:"stake_msat"`
|
StakeMsat int64 `json:"stake_msat"`
|
||||||
Nickname string `json:"nickname"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeErr(w, http.StatusBadRequest, "malformed request")
|
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")
|
writeErr(w, http.StatusNotFound, "no such game")
|
||||||
return
|
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())
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ function setBalance(msat) {
|
|||||||
$('balance').textContent = sats(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 ---------------- */
|
/* ---------------- crash room ---------------- */
|
||||||
|
|
||||||
function connect(game) {
|
function connect(game) {
|
||||||
@@ -145,7 +156,9 @@ function onSnapshot(s) {
|
|||||||
switch (s.state) {
|
switch (s.state) {
|
||||||
case 'betting_open':
|
case 'betting_open':
|
||||||
$('state').textContent = `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
$('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.className = 'primary big';
|
||||||
action.disabled = !!myBet;
|
action.disabled = !!myBet;
|
||||||
break;
|
break;
|
||||||
@@ -189,7 +202,7 @@ function renderPlayers(players) {
|
|||||||
clear(wrap);
|
clear(wrap);
|
||||||
for (const p of players || []) {
|
for (const p of players || []) {
|
||||||
const amount = p.cashed_out
|
const amount = p.cashed_out
|
||||||
? '↑ ' + parseFloat(p.cashed_out).toFixed(2) + '×'
|
? (p.auto ? '⚡ ' : '↑ ') + parseFloat(p.cashed_out).toFixed(2) + '×'
|
||||||
: sats(p.stake_msat) + ' sats';
|
: sats(p.stake_msat) + ' sats';
|
||||||
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
|
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
|
||||||
el('span', { class: 'who', text: p.nickname || 'anon' }),
|
el('span', { class: 'who', text: p.nickname || 'anon' }),
|
||||||
@@ -205,6 +218,7 @@ async function onAction() {
|
|||||||
if (snapshot.state === 'betting_open' && !myBet) {
|
if (snapshot.state === 'betting_open' && !myBet) {
|
||||||
const r = await api('POST', '/api/bet', {
|
const r = await api('POST', '/api/bet', {
|
||||||
game: currentGame, stake_msat: stake, nickname,
|
game: currentGame, stake_msat: stake, nickname,
|
||||||
|
auto_cashout: autoTarget(),
|
||||||
});
|
});
|
||||||
setBalance(r.balance_msat);
|
setBalance(r.balance_msat);
|
||||||
myBet = 'in';
|
myBet = 'in';
|
||||||
@@ -250,11 +264,23 @@ function draw(s) {
|
|||||||
const crashed = s.state === 'settled';
|
const crashed = s.state === 'settled';
|
||||||
const progress = Math.min(1, Math.log(m) / Math.log(12));
|
const progress = Math.min(1, Math.log(m) / Math.log(12));
|
||||||
|
|
||||||
// Starfield drifts downward as you climb.
|
// Scrolling grid: a horizon that rushes past as the multiplier climbs.
|
||||||
ctx.fillStyle = '#ffffff';
|
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) {
|
for (const st of stars) {
|
||||||
const y = (st.y + progress * 0.9) % 1;
|
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.fillRect(st.x * w, y * h, st.r, st.r);
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
@@ -267,13 +293,13 @@ function draw(s) {
|
|||||||
function drawRocket(w, h, p, crashed) {
|
function drawRocket(w, h, p, crashed) {
|
||||||
const x = w * 0.5;
|
const x = w * 0.5;
|
||||||
const y = h * (0.88 - p * 0.66);
|
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.
|
// Exhaust plume: longer and more agitated as the climb steepens.
|
||||||
const plume = 26 + p * 60;
|
const plume = 26 + p * 60;
|
||||||
const g = ctx.createLinearGradient(x, y, x, y + plume);
|
const g = ctx.createLinearGradient(x, y, x, y + plume);
|
||||||
g.addColorStop(0, crashed ? '#ff4d6daa' : '#38f2e4cc');
|
g.addColorStop(0, crashed ? '#ff3355aa' : '#00ff9ccc');
|
||||||
g.addColorStop(1, '#38f2e400');
|
g.addColorStop(1, '#00ff9c00');
|
||||||
ctx.fillStyle = g;
|
ctx.fillStyle = g;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x - 7, y + 8);
|
ctx.moveTo(x - 7, y + 8);
|
||||||
@@ -291,7 +317,7 @@ function drawRocket(w, h, p, crashed) {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
if (crashed) {
|
if (crashed) {
|
||||||
ctx.strokeStyle = '#ff4d6d88';
|
ctx.strokeStyle = '#ff335588';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
for (let i = 0; i < 9; i++) {
|
for (let i = 0; i < 9; i++) {
|
||||||
const a = (i / 9) * Math.PI * 2;
|
const a = (i / 9) * Math.PI * 2;
|
||||||
@@ -306,17 +332,17 @@ function drawRocket(w, h, p, crashed) {
|
|||||||
function drawOrbital(w, h, p, crashed) {
|
function drawOrbital(w, h, p, crashed) {
|
||||||
const cx = w / 2, cy = h / 2;
|
const cx = w / 2, cy = h / 2;
|
||||||
const planet = Math.min(w, h) * 0.16;
|
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();
|
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;
|
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.lineWidth = 1;
|
||||||
ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke();
|
ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
|
||||||
const a = p * Math.PI * 9;
|
const a = p * Math.PI * 9;
|
||||||
const x = cx + Math.cos(a) * orbit, y = cy + Math.sin(a) * orbit;
|
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();
|
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 sway = Math.sin(i * 0.7 + p * 6) * (i / blocks) * 22 * (crashed ? 3 : 1);
|
||||||
const y = h * 0.9 - (i + 1) * bh;
|
const y = h * 0.9 - (i + 1) * bh;
|
||||||
ctx.fillStyle = i === blocks - 1
|
ctx.fillStyle = i === blocks - 1
|
||||||
? (crashed ? '#ff4d6d' : '#38f2e4')
|
? (crashed ? '#ff3355' : '#00ff9c')
|
||||||
: `hsl(${230 + i * 4} 45% ${22 + i}%)`;
|
: `hsl(${160 - i} 90% ${12 + i}%)`;
|
||||||
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
|
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,6 +644,7 @@ async function init() {
|
|||||||
(t.onclick = () => selectTab(t.dataset.view)));
|
(t.onclick = () => selectTab(t.dataset.view)));
|
||||||
document.querySelectorAll('.chip').forEach((c) =>
|
document.querySelectorAll('.chip').forEach((c) =>
|
||||||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||||||
|
$('auto-target').oninput = refreshAutoRow;
|
||||||
|
|
||||||
const pick = $('gamepick');
|
const pick = $('gamepick');
|
||||||
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Quantum Arcade</title>
|
<title>QUANTUM ARCADE</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -11,15 +11,13 @@
|
|||||||
<!-- Ornate background filigree, drawn once as an SVG pattern and tiled. -->
|
<!-- Ornate background filigree, drawn once as an SVG pattern and tiled. -->
|
||||||
<svg class="filigree" aria-hidden="true">
|
<svg class="filigree" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="orn" width="120" height="120" patternUnits="userSpaceOnUse">
|
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||||
<g fill="none" stroke="currentColor" stroke-width="0.6">
|
<g fill="none" stroke="currentColor" stroke-width="0.7">
|
||||||
<circle cx="60" cy="60" r="46"/>
|
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
|
||||||
<circle cx="60" cy="60" r="30"/>
|
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
|
||||||
<circle cx="60" cy="60" r="14"/>
|
<circle cx="30" cy="26" r="1.6"/>
|
||||||
<path d="M60 0 L60 120 M0 60 L120 60"/>
|
<circle cx="0" cy="26" r="1.2"/>
|
||||||
<path d="M17 17 L103 103 M103 17 L17 103"/>
|
<circle cx="60" cy="26" r="1.2"/>
|
||||||
<path d="M60 14 q26 22 0 46 q-26-24 0-46"/>
|
|
||||||
<path d="M14 60 q22 26 46 0 q-24-26-46 0"/>
|
|
||||||
</g>
|
</g>
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
@@ -37,13 +35,13 @@
|
|||||||
|
|
||||||
<!-- Sign-in: a nickname and nothing else. -->
|
<!-- Sign-in: a nickname and nothing else. -->
|
||||||
<section class="panel center" id="signin">
|
<section class="panel center" id="signin">
|
||||||
<h1>Enter the arcade</h1>
|
<h1>ACCESS TERMINAL</h1>
|
||||||
<p class="muted">
|
<p class="muted small">
|
||||||
No account, no email, no password. Your device generates a key that
|
No account. No email. No password. This device generated a keypair that
|
||||||
<em>is</em> your identity. Keep the device, keep the balance.
|
<em>is</em> your identity. Keep the device, keep the balance.
|
||||||
</p>
|
</p>
|
||||||
<input id="nickname" maxlength="20" placeholder="pick a name" autocomplete="off">
|
<input id="nickname" maxlength="20" placeholder="handle" autocomplete="off">
|
||||||
<button class="primary" id="enter">Enter</button>
|
<button class="primary" id="enter">Connect</button>
|
||||||
<p class="fineprint" id="keynote"></p>
|
<p class="fineprint" id="keynote"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -76,6 +74,13 @@
|
|||||||
<button class="chip" data-stake="100000">100</button>
|
<button class="chip" data-stake="100000">100</button>
|
||||||
<span class="unit">sats</span>
|
<span class="unit">sats</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="autorow" id="autorow">
|
||||||
|
<label for="auto-target">auto out</label>
|
||||||
|
<input id="auto-target" type="number" min="1.01" step="0.1"
|
||||||
|
inputmode="decimal" placeholder="off">
|
||||||
|
<span class="x">×</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="primary big" id="action">Place bet</button>
|
<button class="primary big" id="action">Place bet</button>
|
||||||
<div class="hint" id="hint"></div>
|
<div class="hint" id="hint"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
/* Quantum Arcade — obsidian and indigo, dense ornament, and vivid colour
|
/* Quantum Arcade — terminal aesthetic.
|
||||||
reserved strictly for the things that matter: the multiplier, the balance,
|
*
|
||||||
and the button that takes your money out of danger. */
|
* 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 {
|
:root {
|
||||||
--void: #05060d;
|
--black: #000000;
|
||||||
--obsidian: #0a0c18;
|
--panel: #030806;
|
||||||
--indigo: #131a3a;
|
--green: #00ff9c;
|
||||||
--indigo-hi: #1d2757;
|
--green-dim: #0a7a52;
|
||||||
--ink: #c8cbe6;
|
--green-ghost: #063a29;
|
||||||
--muted: #6a719c;
|
--amber: #ffb000;
|
||||||
--cyan: #38f2e4;
|
--magenta: #ff2e88;
|
||||||
--magenta: #ff3ec8;
|
--red: #ff3355;
|
||||||
--gold: #ffc857;
|
--grid: #06181200;
|
||||||
--danger: #ff4d6d;
|
|
||||||
--ornament: #1a2350;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
@@ -21,132 +22,178 @@
|
|||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: radial-gradient(ellipse at 50% -10%, var(--indigo) 0%, var(--obsidian) 45%, var(--void) 100%);
|
background: var(--black);
|
||||||
color: var(--ink);
|
color: var(--green);
|
||||||
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
font: 14px/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
overscroll-behavior: none;
|
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 {
|
.filigree {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
color: var(--ornament);
|
color: #0d2b21;
|
||||||
opacity: 0.55;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
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; }
|
body > * { position: relative; z-index: 1; }
|
||||||
|
|
||||||
/* ---------- chrome ---------- */
|
/* ---------- chrome ---------- */
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 14px 16px calc(14px);
|
padding: 10px 14px;
|
||||||
border-bottom: 1px solid #ffffff10;
|
border-bottom: 1px solid var(--green-ghost);
|
||||||
backdrop-filter: blur(6px);
|
background: #00120c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700; letter-spacing: 0.22em; font-size: 13px;
|
font-weight: 700; letter-spacing: 0.18em; font-size: 12px;
|
||||||
color: var(--ink);
|
|
||||||
}
|
}
|
||||||
.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 {
|
.balance .label {
|
||||||
display: block; font-size: 9px; letter-spacing: 0.2em;
|
display: block; font-size: 9px; letter-spacing: 0.18em;
|
||||||
text-transform: uppercase; color: var(--muted);
|
text-transform: uppercase; color: var(--green-dim);
|
||||||
}
|
}
|
||||||
.balance .value {
|
.balance .value {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 18px; font-weight: 700; color: var(--cyan);
|
font-size: 17px; font-weight: 700; color: var(--amber);
|
||||||
text-shadow: 0 0 18px #38f2e455;
|
text-shadow: 0 0 14px #ffb00066;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sound {
|
.sound {
|
||||||
background: none; border: 1px solid #ffffff20; color: var(--muted);
|
background: none; border: 1px solid var(--green-ghost); color: var(--green-dim);
|
||||||
width: 34px; height: 34px; border-radius: 50%; font-size: 15px;
|
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 ---------- */
|
/* ---------- panels ---------- */
|
||||||
|
|
||||||
.panel {
|
.panel { max-width: 460px; margin: 0 auto; padding: 34px 20px; }
|
||||||
max-width: 460px; margin: 0 auto; padding: 40px 22px;
|
|
||||||
}
|
|
||||||
.center { text-align: center; }
|
.center { text-align: center; }
|
||||||
|
|
||||||
h1 { font-size: 26px; margin: 0 0 10px; letter-spacing: 0.02em; }
|
h1 {
|
||||||
h2 { font-size: 14px; letter-spacing: 0.12em; text-transform: uppercase;
|
font-size: 19px; margin: 0 0 10px; letter-spacing: 0.1em;
|
||||||
color: var(--muted); margin: 0 0 10px; }
|
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); }
|
h2 {
|
||||||
.small { font-size: 12.5px; }
|
font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
.fineprint { font-size: 11px; color: var(--muted); margin-top: 18px; word-break: break-all; }
|
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 {
|
input {
|
||||||
width: 100%; padding: 13px 14px; margin: 8px 0;
|
width: 100%; padding: 11px 12px; margin: 7px 0;
|
||||||
background: #ffffff08; border: 1px solid #ffffff18; border-radius: 10px;
|
background: #00140e; border: 1px solid var(--green-ghost); border-radius: 3px;
|
||||||
color: var(--ink); font-size: 16px; /* 16px stops iOS zooming on focus */
|
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 {
|
button {
|
||||||
font: inherit; cursor: pointer; border-radius: 10px;
|
font: inherit; cursor: pointer; border-radius: 3px;
|
||||||
border: 1px solid #ffffff20; background: #ffffff0c; color: var(--ink);
|
border: 1px solid var(--green-ghost); background: #00140e; color: var(--green);
|
||||||
padding: 11px 16px;
|
padding: 10px 14px; letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
button:active { transform: translateY(1px); }
|
button:active { transform: translateY(1px); }
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
background: linear-gradient(135deg, var(--cyan), #21b6ff);
|
background: #00291c; color: var(--green);
|
||||||
color: #04121a; border: none; font-weight: 700; letter-spacing: 0.04em;
|
border: 1px solid var(--green); font-weight: 700;
|
||||||
box-shadow: 0 0 26px #38f2e444;
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
width: 100%; padding: 15px;
|
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 {
|
.primary.cashout {
|
||||||
background: linear-gradient(135deg, var(--magenta), var(--gold));
|
background: #2a1c00; color: var(--amber); border-color: var(--amber);
|
||||||
color: #1a0512; box-shadow: 0 0 34px #ff3ec866;
|
box-shadow: 0 0 26px #ffb00055, inset 0 0 18px #ffb00011;
|
||||||
animation: urge 900ms ease-in-out infinite;
|
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 {
|
@keyframes urge {
|
||||||
0%, 100% { box-shadow: 0 0 26px #ff3ec855; }
|
0%, 100% { box-shadow: 0 0 18px #ffb00044, inset 0 0 14px #ffb00011; }
|
||||||
50% { box-shadow: 0 0 40px #ff3ec8aa; }
|
50% { box-shadow: 0 0 34px #ffb000aa, inset 0 0 22px #ffb00022; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- tabs ---------- */
|
/* ---------- tabs ---------- */
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex; gap: 6px; padding: 12px 12px 0;
|
display: flex; gap: 4px; padding: 10px 10px 0;
|
||||||
max-width: 620px; margin: 0 auto;
|
max-width: 620px; margin: 0 auto;
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
flex: 1; padding: 10px 4px; font-size: 12px; letter-spacing: 0.08em;
|
flex: 1; padding: 9px 3px; font-size: 11px; letter-spacing: 0.1em;
|
||||||
text-transform: uppercase; background: none; border: none; color: var(--muted);
|
text-transform: uppercase; background: none; border: none;
|
||||||
border-bottom: 2px solid transparent; border-radius: 0;
|
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 ---------- */
|
/* ---------- crash stage ---------- */
|
||||||
|
|
||||||
.gamepick { display: flex; gap: 6px; margin-bottom: 12px; }
|
.gamepick { display: flex; gap: 4px; margin-bottom: 10px; }
|
||||||
.gamepick button {
|
.gamepick button {
|
||||||
flex: 1; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
flex: 1; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
padding: 9px 4px; color: var(--muted);
|
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 {
|
.stage {
|
||||||
position: relative; border-radius: 16px; overflow: hidden;
|
position: relative; border-radius: 3px; overflow: hidden;
|
||||||
border: 1px solid #ffffff14;
|
border: 1px solid var(--green-ghost);
|
||||||
background: linear-gradient(180deg, #070a16 0%, #05060d 100%);
|
background: #000603;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 4 / 3;
|
||||||
}
|
}
|
||||||
#canvas { width: 100%; height: 100%; display: block; }
|
#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;
|
align-items: center; justify-content: center; pointer-events: none;
|
||||||
}
|
}
|
||||||
.multiplier {
|
.multiplier {
|
||||||
font-size: clamp(46px, 17vw, 88px); font-weight: 800;
|
font-size: clamp(42px, 16vw, 82px); font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
|
font-variant-numeric: tabular-nums; letter-spacing: -0.01em;
|
||||||
color: var(--cyan); text-shadow: 0 0 40px #38f2e466;
|
color: var(--magenta); text-shadow: 0 0 30px #ff2e8877;
|
||||||
}
|
}
|
||||||
.multiplier.crashed { color: var(--danger); text-shadow: 0 0 40px #ff4d6d66; }
|
.multiplier.crashed {
|
||||||
.multiplier.won { color: var(--gold); text-shadow: 0 0 46px #ffc85777; }
|
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 {
|
.state {
|
||||||
font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase;
|
font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
|
||||||
color: var(--muted); margin-top: 6px;
|
color: var(--green-dim); margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls { margin-top: 14px; }
|
.controls { margin-top: 12px; }
|
||||||
.stakerow { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
|
.stakerow { display: flex; align-items: center; gap: 4px; margin-bottom: 8px; }
|
||||||
.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 12px 4px; }
|
.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 11px 3px; font-size: 13px; }
|
||||||
.chip.on { border-color: var(--cyan); color: var(--cyan); background: #38f2e414; }
|
.chip.on { border-color: var(--green); color: var(--green); background: #00291c; }
|
||||||
.unit { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; }
|
.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; }
|
/* Auto cash-out target row. */
|
||||||
.hint.bad { color: var(--danger); }
|
.autorow {
|
||||||
.hint.good { color: var(--gold); }
|
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 {
|
.player {
|
||||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
display: flex; align-items: center; gap: 8px; padding: 7px 10px;
|
||||||
background: #ffffff06; border: 1px solid #ffffff10; border-radius: 9px;
|
background: #00120c; border: 1px solid var(--green-ghost); border-radius: 3px;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.player .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.player .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.player .amt { font-variant-numeric: tabular-nums; color: var(--muted); }
|
.player .amt { font-variant-numeric: tabular-nums; color: var(--green-dim); }
|
||||||
.player.out .amt { color: var(--gold); }
|
.player.out { border-color: #4a3300; }
|
||||||
|
.player.out .amt { color: var(--amber); }
|
||||||
|
|
||||||
.proof { margin-top: 18px; }
|
.proof { margin-top: 16px; }
|
||||||
.proof summary {
|
.proof summary {
|
||||||
cursor: pointer; font-size: 11px; letter-spacing: 0.16em;
|
cursor: pointer; font-size: 10px; letter-spacing: 0.16em;
|
||||||
text-transform: uppercase; color: var(--muted); padding: 8px 0;
|
text-transform: uppercase; color: var(--green-dim); padding: 7px 0;
|
||||||
}
|
}
|
||||||
.kv { display: flex; gap: 10px; font-size: 11px; padding: 4px 0; }
|
.kv { display: flex; gap: 8px; font-size: 10.5px; padding: 3px 0; }
|
||||||
.kv span { color: var(--muted); min-width: 96px; }
|
.kv span { color: var(--green-dim); min-width: 92px; }
|
||||||
.kv code { word-break: break-all; color: var(--ink); opacity: 0.8; }
|
.kv code { word-break: break-all; color: var(--green); opacity: 0.75; }
|
||||||
|
|
||||||
/* ---------- scratch ---------- */
|
/* ---------- cards / scratch ---------- */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #ffffff07; border: 1px solid #ffffff14;
|
background: #00120c; border: 1px solid var(--green-ghost);
|
||||||
border-radius: 14px; padding: 16px; margin-bottom: 14px;
|
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 {
|
.grid { display: grid; gap: 6px; margin: 12px 0; }
|
||||||
display: grid; gap: 8px; margin: 14px 0;
|
|
||||||
}
|
|
||||||
.grid.c9 { grid-template-columns: repeat(3, 1fr); }
|
.grid.c9 { grid-template-columns: repeat(3, 1fr); }
|
||||||
.grid.c6 { grid-template-columns: repeat(3, 1fr); }
|
.grid.c6 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
aspect-ratio: 1; display: grid; place-items: center;
|
aspect-ratio: 1; display: grid; place-items: center;
|
||||||
font-size: 26px; border-radius: 10px;
|
font-size: 24px; border-radius: 3px;
|
||||||
background: linear-gradient(140deg, var(--indigo-hi), var(--indigo));
|
background: #001f16; border: 1px solid var(--green-ghost);
|
||||||
border: 1px solid #ffffff14;
|
color: var(--green-dim);
|
||||||
transition: transform 160ms ease, background 260ms ease;
|
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 { width: 100%; border-collapse: collapse; font-size: 11.5px; margin-top: 8px; }
|
||||||
.odds th, .odds td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #ffffff10; }
|
.odds th, .odds td {
|
||||||
.odds th { color: var(--muted); font-weight: 500; font-size: 10px;
|
text-align: left; padding: 5px 3px; border-bottom: 1px solid var(--green-ghost);
|
||||||
letter-spacing: 0.12em; text-transform: uppercase; }
|
}
|
||||||
.odds td:last-child, .odds th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
.odds th {
|
||||||
.rtp { color: var(--gold); font-weight: 700; }
|
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 { text-align: center; padding: 8px 0; font-size: 14px; }
|
||||||
.result.win { color: var(--gold); font-weight: 700; }
|
.result.win {
|
||||||
|
color: var(--amber); font-weight: 700; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- wallet ---------- */
|
/* ---------- wallet ---------- */
|
||||||
|
|
||||||
.pubkey {
|
.pubkey {
|
||||||
display: block; word-break: break-all; font-size: 11px;
|
display: block; word-break: break-all; font-size: 10.5px;
|
||||||
background: #00000055; padding: 10px; border-radius: 8px; margin: 8px 0;
|
background: #000603; padding: 9px; border-radius: 3px; margin: 7px 0;
|
||||||
color: var(--muted);
|
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 {
|
.entry {
|
||||||
display: flex; gap: 10px; font-size: 12px; padding: 8px 10px;
|
display: flex; gap: 8px; font-size: 11.5px; padding: 7px 9px;
|
||||||
background: #ffffff05; border-radius: 8px;
|
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 { font-variant-numeric: tabular-nums; }
|
||||||
.entry .delta.pos { color: var(--cyan); }
|
.entry .delta.pos { color: var(--amber); }
|
||||||
.entry .delta.neg { color: var(--muted); }
|
.entry .delta.neg { color: var(--green-dim); }
|
||||||
.entry .after { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 74px; text-align: right; }
|
.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 { margin-top: 10px; font-size: 11.5px; }
|
||||||
.verify-out .ok { color: var(--cyan); font-weight: 700; }
|
.verify-out .ok { color: var(--green); font-weight: 700; }
|
||||||
.verify-out .bad { color: var(--danger); font-weight: 700; }
|
.verify-out .bad { color: var(--red); font-weight: 700; }
|
||||||
.verify-out .kv code { font-size: 10px; }
|
.verify-out .kv code { font-size: 10px; }
|
||||||
|
|||||||
132
coverage.out
132
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: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: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: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: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: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: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: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: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: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: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:110.74,115.29 4 1
|
||||||
github.com/drjones/quantum-arcade/pkg/fair/fair.go:115.29,117.3 1 0
|
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 0
|
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: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:35.30,36.87 1 1
|
||||||
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:38.2,38.25 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: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: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/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: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:36.40,59.24 7 1
|
||||||
github.com/drjones/quantum-arcade/pkg/sim/crash.go:59.24,61.3 1 0
|
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: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: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/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: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:118.54,124.20 5 1
|
||||||
github.com/drjones/quantum-arcade/pkg/room/room.go:124.20,129.3 4 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: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: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/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: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:57.108,58.24 1 1
|
||||||
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:58.24,60.3 1 0
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:58.24,60.3 1 0
|
||||||
|
|||||||
287
docs/API.md
Normal file
287
docs/API.md
Normal file
@@ -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": "<hex>", "signature": "<128 hex chars>", "nickname": "botto" }
|
||||||
|
|
||||||
|
→ { "token": "<hex>", "balance_msat": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass the token on every subsequent request:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
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": "<hex>", "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": "<hex>", // published before betting opens
|
||||||
|
"server_seed": "<hex>", // present only once settled
|
||||||
|
"crash_point": "3.472190", // present only once settled
|
||||||
|
"players": [ { "nickname": "botto", "pubkey": "<hex>",
|
||||||
|
"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": "<hex>", "server_seed": "<hex>",
|
||||||
|
"client_seed": "<hex>", "crash_point": 14914127396,
|
||||||
|
"participants": ["<hex>", "<hex>"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -50,6 +50,12 @@ type Bet struct {
|
|||||||
StakeMsat int64
|
StakeMsat int64
|
||||||
CashedOutAt fixed.F // zero until they cash out
|
CashedOutAt fixed.F // zero until they cash out
|
||||||
PayoutMsat int64
|
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
|
// 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"`
|
StakeMsat int64 `json:"stake_msat"`
|
||||||
CashedOut string `json:"cashed_out,omitempty"`
|
CashedOut string `json:"cashed_out,omitempty"`
|
||||||
PayoutMsat int64 `json:"payout_msat"`
|
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.
|
// Room runs one game's round loop.
|
||||||
@@ -188,6 +196,10 @@ func (r *Room) step(ctx context.Context) error {
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.tick++
|
r.tick++
|
||||||
reached := sim.MultiplierAt(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
|
// A crash point beyond what the curve expresses would otherwise never
|
||||||
// be reached, so the tick ceiling also ends the round.
|
// be reached, so the tick ceiling also ends the round.
|
||||||
crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks
|
crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks
|
||||||
@@ -321,12 +333,35 @@ func (r *Room) settle(ctx context.Context) error {
|
|||||||
return nil
|
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
|
// 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.
|
// 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 {
|
if stakeMsat <= 0 {
|
||||||
return ledger.ErrNonPositiveAmount
|
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()
|
r.mu.Lock()
|
||||||
if r.state != StateBetting {
|
if r.state != StateBetting {
|
||||||
@@ -367,6 +402,7 @@ func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nic
|
|||||||
r.bets[accountID] = &Bet{
|
r.bets[accountID] = &Bet{
|
||||||
AccountID: accountID, Pubkey: pubkey,
|
AccountID: accountID, Pubkey: pubkey,
|
||||||
Nickname: nickname, StakeMsat: stakeMsat,
|
Nickname: nickname, StakeMsat: stakeMsat,
|
||||||
|
AutoCashOutAt: autoCashOutAt,
|
||||||
}
|
}
|
||||||
r.order = append(r.order, pubkey)
|
r.order = append(r.order, pubkey)
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -423,6 +459,7 @@ func (r *Room) Snapshot() Snapshot {
|
|||||||
}
|
}
|
||||||
if b.CashedOutAt != 0 {
|
if b.CashedOutAt != 0 {
|
||||||
p.CashedOut = b.CashedOutAt.String()
|
p.CashedOut = b.CashedOutAt.String()
|
||||||
|
p.Auto = b.AutoCashOutAt != 0 && b.CashedOutAt == b.AutoCashOutAt
|
||||||
}
|
}
|
||||||
players = append(players, p)
|
players = append(players, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func TestPlaceBetDebitsStakeImmediately(t *testing.T) {
|
|||||||
f.openBetting()
|
f.openBetting()
|
||||||
|
|
||||||
before, _ := f.ledger.Balance(f.ctx, id)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
after, _ := f.ledger.Balance(f.ctx, id)
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
@@ -204,13 +204,13 @@ func TestCannotBetOutsideBettingWindow(t *testing.T) {
|
|||||||
id, pk := f.player("a", 10_000)
|
id, pk := f.player("a", 10_000)
|
||||||
|
|
||||||
// Room starts settled.
|
// 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")
|
t.Fatal("bet accepted while settled")
|
||||||
}
|
}
|
||||||
|
|
||||||
f.openBetting()
|
f.openBetting()
|
||||||
f.startRun()
|
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")
|
t.Fatal("bet accepted while running")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,10 +220,10 @@ func TestCannotBetTwiceInOneRound(t *testing.T) {
|
|||||||
id, pk := f.player("a", 10_000)
|
id, pk := f.player("a", 10_000)
|
||||||
f.openBetting()
|
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)
|
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")
|
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)
|
id, pk := f.player("a", 1_000)
|
||||||
f.openBetting()
|
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")
|
t.Fatal("bet larger than balance was accepted")
|
||||||
}
|
}
|
||||||
// And nothing was taken.
|
// And nothing was taken.
|
||||||
@@ -248,7 +248,7 @@ func TestNonPositiveStakesRejected(t *testing.T) {
|
|||||||
f.openBetting()
|
f.openBetting()
|
||||||
|
|
||||||
for _, stake := range []int64{0, -1, -5_000} {
|
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)
|
t.Fatalf("stake %d was accepted", stake)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ func TestCashOutOnlyWhileRunning(t *testing.T) {
|
|||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
id, pk := f.player("a", 10_000)
|
id, pk := f.player("a", 10_000)
|
||||||
f.openBetting()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ func TestCannotCashOutTwice(t *testing.T) {
|
|||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
id, pk := f.player("a", 10_000)
|
id, pk := f.player("a", 10_000)
|
||||||
f.openBetting()
|
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.startRun()
|
||||||
f.forceCrashPoint(100)
|
f.forceCrashPoint(100)
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ func TestCannotCashOutAfterTheCrash(t *testing.T) {
|
|||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
id, pk := f.player("a", 10_000)
|
id, pk := f.player("a", 10_000)
|
||||||
f.openBetting()
|
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.startRun()
|
||||||
|
|
||||||
// Jump past the crash point without letting the loop settle.
|
// Jump past the crash point without letting the loop settle.
|
||||||
@@ -338,7 +338,7 @@ func TestCashedOutPlayerIsPaid(t *testing.T) {
|
|||||||
|
|
||||||
f.openBetting()
|
f.openBetting()
|
||||||
const stake = 10_000
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
f.startRun()
|
f.startRun()
|
||||||
@@ -376,7 +376,7 @@ func TestPlayerWhoDidNotCashOutGetsNothing(t *testing.T) {
|
|||||||
id, pk := f.player("a", 100_000)
|
id, pk := f.player("a", 100_000)
|
||||||
|
|
||||||
f.openBetting()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
f.startRun()
|
f.startRun()
|
||||||
@@ -404,7 +404,7 @@ func TestBooksBalanceAcrossAFullRound(t *testing.T) {
|
|||||||
var ids []int64
|
var ids []int64
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
id, pk := f.player(fmt.Sprintf("p%d", i), 100_000)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
@@ -447,7 +447,7 @@ func TestCrashPointDerivesFromCommittedSeedAndPlayers(t *testing.T) {
|
|||||||
id, pk := f.player("a", 100_000)
|
id, pk := f.player("a", 100_000)
|
||||||
|
|
||||||
f.openBetting()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
f.startRun()
|
f.startRun()
|
||||||
@@ -560,7 +560,7 @@ func TestConcurrentBetsAreAllRecorded(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int, a acct) {
|
go func(i int, a acct) {
|
||||||
defer wg.Done()
|
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)
|
}(i, a)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -586,7 +586,7 @@ func TestConcurrentCashOutsYieldOne(t *testing.T) {
|
|||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
id, pk := f.player("a", 100_000)
|
id, pk := f.player("a", 100_000)
|
||||||
f.openBetting()
|
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.startRun()
|
||||||
f.forceCrashPoint(100)
|
f.forceCrashPoint(100)
|
||||||
|
|
||||||
@@ -619,7 +619,7 @@ func TestSnapshotReportsCashOutMultiplier(t *testing.T) {
|
|||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
id, pk := f.player("a", 100_000)
|
id, pk := f.player("a", 100_000)
|
||||||
f.openBetting()
|
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.startRun()
|
||||||
f.forceCrashPoint(100)
|
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())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,11 +189,11 @@ var Catalog = []Ticket{
|
|||||||
Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.",
|
Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.",
|
||||||
Cells: 9,
|
Cells: 9,
|
||||||
// Weights are out of 1,000,000 and sum to it exactly. The weighted
|
// 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{
|
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: "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: "Five times", Weight: 30_000, PayoutBP: 50_000},
|
||||||
{Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000},
|
{Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000},
|
||||||
{Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000},
|
{Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000},
|
||||||
@@ -204,13 +204,13 @@ var Catalog = []Ticket{
|
|||||||
Name: "Singularity",
|
Name: "Singularity",
|
||||||
Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.",
|
Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.",
|
||||||
Cells: 6,
|
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.
|
// you lose far more often, and the top prize is 500x.
|
||||||
Tiers: []Tier{
|
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: "Stake back", Weight: 60_000, PayoutBP: 10_000},
|
||||||
{Name: "Triple", Weight: 45_000, PayoutBP: 30_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: "Hundred times", Weight: 3_000, PayoutBP: 1_000_000},
|
||||||
{Name: "Singularity", Weight: 470, PayoutBP: 5_000_000},
|
{Name: "Singularity", Weight: 470, PayoutBP: 5_000_000},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ func TestCatalogIsCoherent(t *testing.T) {
|
|||||||
func TestPublishedRTPIsHonest(t *testing.T) {
|
func TestPublishedRTPIsHonest(t *testing.T) {
|
||||||
for _, ticket := range scratch.Catalog {
|
for _, ticket := range scratch.Catalog {
|
||||||
rtp := ticket.RTPBasisPoints()
|
rtp := ticket.RTPBasisPoints()
|
||||||
if rtp != 9800 {
|
if rtp != 9900 {
|
||||||
t.Errorf("%s: RTP = %d bp, want 9800", ticket.ID, rtp)
|
t.Errorf("%s: RTP = %d bp, want 9900", ticket.ID, rtp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import (
|
|||||||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HouseEdgeBP is the house edge in basis points (200 = 2.00%).
|
// HouseEdgeBP is the house edge in basis points (100 = 1.00%).
|
||||||
const HouseEdgeBP int64 = 200
|
//
|
||||||
|
// 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.
|
// TickHz is the simulation rate. Rounds advance in whole ticks only.
|
||||||
const TickHz = 60
|
const TickHz = 60
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ func TestHouseEdgeAtTwoX(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pct := float64(wins) * 100 / n
|
pct := float64(wins) * 100 / n
|
||||||
if pct < 47.5 || pct > 50.5 {
|
if pct < 48.5 || pct > 51.0 {
|
||||||
t.Fatalf("win rate at 2.00x = %.2f%%, want ~49%%", pct)
|
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
|
rtp := returned * 100 / n
|
||||||
if rtp < 96.0 || rtp > 100.0 {
|
if rtp < 97.0 || rtp > 101.0 {
|
||||||
t.Fatalf("RTP at %dx = %.2f%%, want ~98%%", targetX, rtp)
|
t.Fatalf("RTP at %dx = %.2f%%, want ~99%%", targetX, rtp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user