Deposit shows a scannable QR, because nobody types a 400-character invoice at a party. The QR encoder is written rather than imported: a CDN script is a dependency on the outside world, and this box has to work on a network with no internet. Its tests caught a real bug — the format-information loop wrote eight cells down column 8, but the eighth is the dark module, which is fixed. Overwriting it produces symbols some readers reject. The deposit and withdraw cards stay hidden unless the server reports a node, so a play-money deployment does not advertise a deposit it cannot honour. Settlement is polled and confirmed server-side against the node, so the client cannot claim a payment that never arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
910 lines
30 KiB
JavaScript
910 lines
30 KiB
JavaScript
/* Quantum Arcade client.
|
||
*
|
||
* Identity is a keypair generated in the browser and kept in localStorage.
|
||
* There is no account to create and no password to lose.
|
||
*
|
||
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
|
||
* the server whether a round was fair — it checks.
|
||
*
|
||
* All dynamic content is inserted with textContent or built as DOM nodes.
|
||
* Nothing that originates from another player (nicknames, keys) or from the
|
||
* server ever reaches innerHTML. */
|
||
|
||
import { Arcade3D } from '/scene3d.js';
|
||
import * as charts from '/charts.js';
|
||
import * as qr from '/qr.js';
|
||
|
||
const KEY_STORAGE = 'quantum-arcade-key';
|
||
const NAME_STORAGE = 'quantum-arcade-name';
|
||
const STATS_STORAGE = 'quantum-arcade-stats';
|
||
|
||
let keypair = null;
|
||
let token = null;
|
||
let nickname = '';
|
||
let stake = 5000; // millisatoshis
|
||
let currentGame = 'rocket';
|
||
let socket = null;
|
||
let snapshot = null;
|
||
let myBet = null; // 'in' | 'out' | null
|
||
let scene = null;
|
||
let balanceMsat = 0;
|
||
|
||
/* Session statistics, kept client-side. The ledger remains the authority on
|
||
* money; this is only for the charts. */
|
||
const stats = loadStats();
|
||
|
||
function loadStats() {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(STATS_STORAGE) || '{}');
|
||
return {
|
||
crashes: raw.crashes || [], // crash points seen, for distribution
|
||
balances: raw.balances || [], // balance samples over time
|
||
wagered: raw.wagered || 0,
|
||
plays: raw.plays || 0,
|
||
wins: raw.wins || 0,
|
||
losses: raw.losses || 0,
|
||
best: raw.best || 0,
|
||
sessionStart: null, // set at sign-in, never persisted
|
||
};
|
||
} catch {
|
||
return { crashes: [], balances: [], wagered: 0, plays: 0,
|
||
wins: 0, losses: 0, best: 0, sessionStart: null };
|
||
}
|
||
}
|
||
|
||
function saveStats() {
|
||
// Cap the arrays so localStorage cannot grow without bound over a long night.
|
||
stats.crashes = stats.crashes.slice(-300);
|
||
stats.balances = stats.balances.slice(-300);
|
||
localStorage.setItem(STATS_STORAGE, JSON.stringify(stats));
|
||
}
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
|
||
|
||
/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */
|
||
function el(tag, attrs, ...children) {
|
||
const node = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (k === 'class') node.className = v;
|
||
else if (k === 'text') node.textContent = v;
|
||
else node.setAttribute(k, v);
|
||
}
|
||
for (const c of children) {
|
||
if (c == null) continue;
|
||
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
function clear(node) {
|
||
while (node.firstChild) node.removeChild(node.firstChild);
|
||
}
|
||
|
||
/* A short haptic tap. Phones only, and silently absent elsewhere. */
|
||
function buzz(ms) {
|
||
if (navigator.vibrate) navigator.vibrate(ms);
|
||
}
|
||
|
||
/* ---------------- identity ---------------- */
|
||
|
||
async function loadOrCreateKey() {
|
||
const stored = localStorage.getItem(KEY_STORAGE);
|
||
if (stored) {
|
||
const jwk = JSON.parse(stored);
|
||
const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']);
|
||
return { privateKey: priv, publicKeyHex: jwk.qa_pub };
|
||
}
|
||
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||
const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
|
||
const pubHex = hex(rawPub);
|
||
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||
jwk.qa_pub = pubHex;
|
||
localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk));
|
||
return { privateKey: kp.privateKey, publicKeyHex: pubHex };
|
||
}
|
||
|
||
function hex(bytes) {
|
||
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
function unhex(s) {
|
||
const out = new Uint8Array(s.length / 2);
|
||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
||
return out;
|
||
}
|
||
|
||
async function signIn() {
|
||
nickname = ($('nickname').value || 'anon').trim().slice(0, 20);
|
||
localStorage.setItem(NAME_STORAGE, nickname);
|
||
|
||
const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex });
|
||
const sig = new Uint8Array(await crypto.subtle.sign(
|
||
{ name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge)));
|
||
|
||
const res = await api('POST', '/api/auth/verify', {
|
||
pubkey: keypair.publicKeyHex,
|
||
signature: hex(sig),
|
||
nickname,
|
||
});
|
||
token = res.token;
|
||
|
||
$('signin').hidden = true;
|
||
$('app').hidden = false;
|
||
$('tabs').hidden = false;
|
||
$('balance-wrap').hidden = false;
|
||
setBalance(res.balance_msat);
|
||
stats.sessionStart = res.balance_msat;
|
||
$('pubkey').textContent = keypair.publicKeyHex;
|
||
|
||
scene = new Arcade3D($('scene'));
|
||
scene.setGame(currentGame);
|
||
scene.start();
|
||
|
||
connect(currentGame);
|
||
loadScratch();
|
||
detectLightning();
|
||
}
|
||
|
||
/* ---------------- api ---------------- */
|
||
|
||
async function api(method, path, body) {
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||
const res = await fetch(path, {
|
||
method, headers,
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||
return data;
|
||
}
|
||
|
||
function setBalance(msat) {
|
||
balanceMsat = msat;
|
||
$('balance').textContent = sats(msat);
|
||
const last = stats.balances[stats.balances.length - 1];
|
||
if (last !== msat) {
|
||
stats.balances.push(msat);
|
||
saveStats();
|
||
}
|
||
}
|
||
|
||
/* ---------------- auto cash-out ---------------- */
|
||
|
||
function autoTarget() {
|
||
const v = parseFloat($('auto-target').value);
|
||
return Number.isFinite(v) && v > 1 ? v : 0;
|
||
}
|
||
|
||
function refreshAutoRow() {
|
||
$('autorow').classList.toggle('armed', autoTarget() > 0);
|
||
}
|
||
|
||
/* ---------------- crash room ---------------- */
|
||
|
||
function connect(game) {
|
||
if (socket) socket.close();
|
||
currentGame = game;
|
||
myBet = null;
|
||
if (scene) scene.setGame(game);
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
|
||
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
|
||
socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
|
||
}
|
||
|
||
/* The server sends a frame a few times a second; the multiplier curve is
|
||
* deterministic, so between frames the client computes it locally from the
|
||
* round's start time. This is why the animation is smooth without the server
|
||
* pushing sixty frames a second to every phone. */
|
||
const TICK_HZ = 60;
|
||
const ROUND_TICKS = 60 * TICK_HZ;
|
||
|
||
function multiplierAtTick(tick) {
|
||
if (tick <= 0) return 1;
|
||
if (tick >= ROUND_TICKS) tick = ROUND_TICKS - 1;
|
||
const remaining = 1 - tick / ROUND_TICKS;
|
||
return 1 / (remaining * remaining);
|
||
}
|
||
|
||
function localMultiplier() {
|
||
if (!snapshot || snapshot.state !== 'running' || !snapshot.started_unix_milli) {
|
||
return snapshot ? parseFloat(snapshot.multiplier) : 1;
|
||
}
|
||
const elapsedMs = Date.now() - snapshot.started_unix_milli;
|
||
return multiplierAtTick(Math.floor((elapsedMs / 1000) * TICK_HZ));
|
||
}
|
||
|
||
/* Runs every animation frame while a round is in flight, so the number and the
|
||
* 3D scene update at display rate rather than at network rate. */
|
||
function interpolate() {
|
||
if (!snapshot || snapshot.state !== 'running') return;
|
||
const m = localMultiplier();
|
||
$('multiplier').textContent = m.toFixed(2) + '×';
|
||
if (scene) scene.setState(Math.log(Math.max(1, m)) / Math.log(25), false);
|
||
if (myBet === 'in') {
|
||
$('action').textContent = `Cash out ${sats(stake * m)}`;
|
||
}
|
||
requestAnimationFrame(interpolate);
|
||
}
|
||
|
||
function onSnapshot(s) {
|
||
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
|
||
const wasRunning = snapshot && snapshot.state === 'running';
|
||
const wasSettled = snapshot && snapshot.state === 'settled';
|
||
snapshot = s;
|
||
if (roundChanged) myBet = null;
|
||
|
||
const mult = $('multiplier');
|
||
const current = parseFloat(s.multiplier);
|
||
mult.textContent = current.toFixed(2) + '×';
|
||
mult.className = 'multiplier';
|
||
|
||
$('commitment').textContent = s.commitment || '—';
|
||
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
|
||
|
||
// Counts come from the server aggregate: the player list in each frame is
|
||
// only the leaderboard, capped so a large room stays cheap to broadcast.
|
||
$('potline').textContent = s.player_count
|
||
? `${s.player_count} in · ${sats(s.pot_msat)} sats at stake` +
|
||
(s.cashed_out_count ? ` · ${s.cashed_out_count} out` : '')
|
||
: 'no bets yet';
|
||
|
||
// Drive the 3D scene. Progress is log-scaled so the early climb is visible
|
||
// and the tail does not saturate instantly.
|
||
if (scene) {
|
||
scene.setState(Math.log(Math.max(1, current)) / Math.log(25),
|
||
s.state === 'settled');
|
||
}
|
||
|
||
const action = $('action');
|
||
const hint = $('hint');
|
||
|
||
switch (s.state) {
|
||
case 'betting_open':
|
||
$('state').textContent =
|
||
`betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||
action.textContent = myBet
|
||
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
|
||
: 'Place bet';
|
||
action.className = 'primary big';
|
||
action.disabled = !!myBet;
|
||
break;
|
||
|
||
case 'locked':
|
||
$('state').textContent = 'launching';
|
||
action.textContent = 'Launching…';
|
||
action.disabled = true;
|
||
break;
|
||
|
||
case 'running': {
|
||
$('state').textContent = 'in flight';
|
||
if (!wasRunning) requestAnimationFrame(interpolate);
|
||
if (myBet === 'in') {
|
||
const payout = stake * current;
|
||
action.textContent = `Cash out ${sats(payout)}`;
|
||
action.className = 'primary big cashout';
|
||
action.disabled = false;
|
||
} else {
|
||
action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching';
|
||
action.className = 'primary big';
|
||
action.disabled = true;
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'settled': {
|
||
const crash = s.crash_point ? parseFloat(s.crash_point) : current;
|
||
mult.textContent = crash.toFixed(2) + '×';
|
||
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||
$('state').textContent =
|
||
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||
action.textContent = 'Bet again';
|
||
action.className = 'primary big';
|
||
action.disabled = false;
|
||
|
||
// Record the round once, on the transition into settled.
|
||
if (!wasSettled && s.crash_point) {
|
||
stats.crashes.push(crash);
|
||
if (myBet === 'in') {
|
||
stats.losses++;
|
||
// Near-miss psychology: show how close they were
|
||
const autoTarget = parseFloat($('auto-target').value || '0');
|
||
if (autoTarget > 1 && crash > 1.0 && crash < autoTarget) {
|
||
hint.innerHTML = `Almost! Crashed at ${crash.toFixed(2)}× — you were ${((autoTarget - crash) * 100).toFixed(0)}% away from ${autoTarget.toFixed(2)}×.`;
|
||
hint.className = 'hint near-miss';
|
||
} else {
|
||
hint.textContent = crash < 1.5 ? 'Brutal — early crash.' : crash < 3 ? 'Rode it too far.' : 'So close to a monster.';
|
||
hint.className = 'hint bad';
|
||
}
|
||
stats.streak = 0;
|
||
buzz(120);
|
||
} else if (myBet === 'out') {
|
||
stats.wins++;
|
||
stats.streak = (stats.streak || 0) + 1;
|
||
const payout = Math.round(stake * crash / 1000);
|
||
hint.textContent = stats.streak >= 5
|
||
? `🔥 ${stats.streak} IN A ROW! +${sats(payout * 1000)} sats`
|
||
: stats.streak >= 3
|
||
? `On fire! ${stats.streak} wins straight. +${sats(payout * 1000)} sats`
|
||
: `Won +${sats(payout * 1000)} sats at ${crash.toFixed(2)}×`;
|
||
hint.className = 'hint good';
|
||
if (stats.streak >= 3) buzz([20, 30, 20, 30, 40]);
|
||
else buzz([30, 40, 30]);
|
||
// Auto-increment stake on hot streak
|
||
if (stats.streak >= 3 && stake < 25000) {
|
||
const newStake = stake * 2;
|
||
setStake(newStake);
|
||
hint.textContent += ' • Stake doubled!';
|
||
}
|
||
}
|
||
if (!stats.best || crash > stats.best) stats.best = crash;
|
||
saveStats();
|
||
renderStrip();
|
||
refreshBalance();
|
||
}
|
||
// Pre-fill for instant re-bet: auto-bet on next round
|
||
if (!wasSettled && myBet === 'out') {
|
||
action.classList.add('pulse');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
renderPlayers(s.players);
|
||
}
|
||
|
||
/* The recent-rounds strip under the stage. */
|
||
function renderStrip() {
|
||
const wrap = $('strip');
|
||
clear(wrap);
|
||
for (const c of stats.crashes.slice(-24).reverse()) {
|
||
const cls = c >= 10 ? 'pip high' : c >= 2 ? 'pip mid' : 'pip';
|
||
wrap.appendChild(el('span', { class: cls, text: c.toFixed(2) + '×' }));
|
||
}
|
||
}
|
||
|
||
function renderPlayers(players) {
|
||
const wrap = $('players');
|
||
clear(wrap);
|
||
for (const p of players || []) {
|
||
const amount = p.cashed_out
|
||
? (p.auto ? '⚡ ' : '↑ ') + parseFloat(p.cashed_out).toFixed(2) + '×'
|
||
: sats(p.stake_msat) + ' sats';
|
||
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
|
||
el('span', { class: 'who', text: p.nickname || 'anon' }),
|
||
el('span', { class: 'amt', text: amount })));
|
||
}
|
||
}
|
||
|
||
async function refreshBalance() {
|
||
try {
|
||
const b = await api('GET', '/api/balance');
|
||
setBalance(b.balance_msat);
|
||
} catch { /* a failed refresh is cosmetic; the ledger is still correct */ }
|
||
}
|
||
|
||
async function onAction() {
|
||
const hint = $('hint');
|
||
hint.textContent = '';
|
||
hint.className = 'hint';
|
||
try {
|
||
if (snapshot.state === 'betting_open' && !myBet) {
|
||
const r = await api('POST', '/api/bet', {
|
||
game: currentGame, stake_msat: stake, nickname,
|
||
auto_cashout: autoTarget(),
|
||
});
|
||
setBalance(r.balance_msat);
|
||
stats.wagered += stake;
|
||
stats.plays++;
|
||
saveStats();
|
||
myBet = 'in';
|
||
buzz(20);
|
||
} else if (snapshot.state === 'running' && myBet === 'in') {
|
||
const r = await api('POST', '/api/cashout', { game: currentGame });
|
||
myBet = 'out';
|
||
const at = parseFloat(r.cashed_out_at);
|
||
const won = stake * at;
|
||
if (won > stats.best) stats.best = won;
|
||
saveStats();
|
||
hint.textContent = `Out at ${at.toFixed(2)}× — paid at settlement.`;
|
||
hint.className = 'hint good';
|
||
buzz([25, 30, 25]);
|
||
refreshBalance();
|
||
}
|
||
} catch (e) {
|
||
hint.textContent = e.message;
|
||
hint.className = 'hint bad';
|
||
}
|
||
}
|
||
|
||
/* ---------------- ambient sound ---------------- */
|
||
|
||
let audio = null;
|
||
|
||
function toggleSound() {
|
||
if (audio) {
|
||
audio.close(); audio = null;
|
||
$('sound-toggle').classList.remove('on');
|
||
return;
|
||
}
|
||
audio = new (window.AudioContext || window.webkitAudioContext)();
|
||
const gain = audio.createGain();
|
||
gain.gain.value = 0.05;
|
||
gain.connect(audio.destination);
|
||
|
||
// Two detuned oscillators a fifth apart: calm, wide, and never resolving.
|
||
[55, 82.5].forEach((f) => {
|
||
const o = audio.createOscillator();
|
||
o.type = 'sine';
|
||
o.frequency.value = f;
|
||
const lfo = audio.createOscillator();
|
||
lfo.frequency.value = 0.05 + Math.random() * 0.06;
|
||
const depth = audio.createGain();
|
||
depth.gain.value = 1.5;
|
||
lfo.connect(depth).connect(o.frequency);
|
||
o.connect(gain);
|
||
o.start(); lfo.start();
|
||
});
|
||
$('sound-toggle').classList.add('on');
|
||
}
|
||
|
||
/* ---------------- scratch tickets ---------------- */
|
||
|
||
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
|
||
|
||
async function loadScratch() {
|
||
const { tickets } = await api('GET', '/api/scratch/catalog');
|
||
const wrap = $('tickets');
|
||
clear(wrap);
|
||
|
||
for (const t of tickets) {
|
||
const grid = el('div', { class: 'grid c' + t.cells });
|
||
for (let i = 0; i < t.cells; i++) {
|
||
grid.appendChild(el('div', { class: 'cell', text: '?' }));
|
||
}
|
||
|
||
const result = el('div', { class: 'result' });
|
||
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
|
||
button.dataset.play = t.id;
|
||
button.onclick = () => playScratch(t, grid, result, button);
|
||
|
||
const table = el('table', { class: 'odds' },
|
||
el('tr', {},
|
||
el('th', { text: 'prize' }),
|
||
el('th', { text: 'pays' }),
|
||
el('th', { text: 'chance' })));
|
||
for (const o of t.odds) {
|
||
table.appendChild(el('tr', {},
|
||
el('td', { text: o.tier }),
|
||
el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }),
|
||
el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' })));
|
||
}
|
||
|
||
wrap.appendChild(el('div', { class: 'card' },
|
||
el('h3', { text: t.name }),
|
||
el('p', { class: 'muted small', text: t.blurb }),
|
||
grid,
|
||
result,
|
||
button,
|
||
table,
|
||
el('p', { class: 'small' },
|
||
'Return to player: ',
|
||
el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }),
|
||
'. These odds are read from the same table that generates results.')));
|
||
}
|
||
}
|
||
|
||
async function playScratch(t, grid, result, button) {
|
||
result.textContent = '';
|
||
result.className = 'result';
|
||
button.disabled = true;
|
||
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
|
||
|
||
let out;
|
||
try {
|
||
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
|
||
} catch (e) {
|
||
result.textContent = e.message;
|
||
button.disabled = false;
|
||
return;
|
||
}
|
||
setBalance(out.balance_msat);
|
||
stats.wagered += stake;
|
||
stats.plays++;
|
||
if (out.outcome.payout_msat > stats.best) stats.best = out.outcome.payout_msat;
|
||
if (out.outcome.payout_msat > 0) stats.wins++; else stats.losses++;
|
||
saveStats();
|
||
|
||
const cells = out.outcome.cells;
|
||
const counts = {};
|
||
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
|
||
const winner = Object.keys(counts).find((k) => counts[k] >= 3);
|
||
|
||
cells.forEach((sym, i) => {
|
||
setTimeout(() => {
|
||
const cell = grid.children[i];
|
||
cell.textContent = SYMBOLS[sym];
|
||
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
|
||
buzz(8);
|
||
if (i === cells.length - 1) {
|
||
const won = out.outcome.payout_msat > 0;
|
||
result.className = 'result' + (won ? ' win' : '');
|
||
result.textContent = won
|
||
? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
|
||
: 'No win this time';
|
||
if (won) buzz([40, 50, 40]);
|
||
button.disabled = false;
|
||
}
|
||
}, i * 120);
|
||
});
|
||
}
|
||
|
||
/* ---------------- portfolio ---------------- */
|
||
|
||
function renderPortfolio() {
|
||
$('t-balance').textContent = sats(balanceMsat);
|
||
charts.sparkline($('spark-balance'), stats.balances.slice(-40));
|
||
|
||
const delta = stats.sessionStart == null ? 0 : balanceMsat - stats.sessionStart;
|
||
const sess = $('t-session');
|
||
sess.textContent = (delta >= 0 ? '+' : '') + sats(delta);
|
||
sess.className = 'tile-value ' + (delta > 0 ? 'up' : delta < 0 ? 'down' : '');
|
||
|
||
$('t-wagered').textContent = sats(stats.wagered);
|
||
$('t-plays').textContent = `${stats.plays} play${stats.plays === 1 ? '' : 's'}`;
|
||
$('t-best').textContent = sats(stats.best);
|
||
|
||
charts.balanceChart($('chart-balance'),
|
||
stats.balances.map((b) => ({ BalanceAfter: b })));
|
||
charts.winLossDonut($('chart-donut'), stats.wins, stats.losses);
|
||
charts.distributionChart($('chart-dist'), stats.crashes);
|
||
charts.crashHistoryChart($('chart-history'), stats.crashes);
|
||
|
||
const total = stats.wins + stats.losses;
|
||
$('donut-note').textContent = total
|
||
? `${stats.wins} cashed out, ${stats.losses} rode into the crash, across ${total} rounds.`
|
||
: '';
|
||
}
|
||
|
||
/* ---------------- lightning ----------------
|
||
*
|
||
* The deposit and withdraw cards stay hidden unless the server reports a node,
|
||
* so a play-money deployment does not advertise a deposit it cannot honour. */
|
||
|
||
let depositHash = null;
|
||
let depositPoll = null;
|
||
|
||
async function detectLightning() {
|
||
try {
|
||
// A zero-amount request is rejected either way; what distinguishes the two
|
||
// cases is whether the server says "not configured" or "bad amount".
|
||
const res = await fetch('/api/deposit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json',
|
||
Authorization: 'Bearer ' + token },
|
||
body: JSON.stringify({ amount_sats: 0 }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
const configured = !(data.error || '').includes('lightning not configured');
|
||
$('card-deposit').hidden = !configured;
|
||
$('card-withdraw').hidden = !configured;
|
||
} catch {
|
||
$('card-deposit').hidden = true;
|
||
$('card-withdraw').hidden = true;
|
||
}
|
||
}
|
||
|
||
async function createDeposit() {
|
||
const hint = $('dep-hint');
|
||
hint.textContent = '';
|
||
hint.className = 'hint';
|
||
|
||
const amount = Math.floor(Number($('dep-amt').value));
|
||
if (!Number.isFinite(amount) || amount < 1) {
|
||
hint.textContent = 'Enter an amount in sats.';
|
||
hint.className = 'hint bad';
|
||
return;
|
||
}
|
||
|
||
let res;
|
||
try {
|
||
res = await api('POST', '/api/deposit', { amount_sats: amount });
|
||
} catch (e) {
|
||
hint.textContent = e.message;
|
||
hint.className = 'hint bad';
|
||
return;
|
||
}
|
||
|
||
depositHash = res.payment_hash;
|
||
$('dep-bolt11').textContent = res.invoice;
|
||
$('dep-invoice').hidden = false;
|
||
|
||
const wrap = $('dep-qr');
|
||
clear(wrap);
|
||
// Uppercase for the QR: bech32 is case-insensitive and uppercase encodes far
|
||
// more densely, which keeps the symbol scannable on a phone screen.
|
||
const matrix = qr.encode(res.invoice.toUpperCase());
|
||
if (matrix) {
|
||
wrap.appendChild(qr.render(matrix));
|
||
} else {
|
||
wrap.textContent = 'Invoice too long to show as a code — copy it instead.';
|
||
}
|
||
|
||
$('dep-status').textContent = 'Waiting for payment…';
|
||
startDepositPolling();
|
||
}
|
||
|
||
/* Poll for settlement. The server confirms with the node before crediting, so
|
||
* this cannot be used to claim a payment that never arrived. */
|
||
function startDepositPolling() {
|
||
if (depositPoll) clearInterval(depositPoll);
|
||
let attempts = 0;
|
||
depositPoll = setInterval(async () => {
|
||
if (!depositHash || ++attempts > 120) { // give up after ~10 minutes
|
||
clearInterval(depositPoll);
|
||
depositPoll = null;
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api('POST', '/api/deposit/check',
|
||
{ payment_hash: depositHash });
|
||
if (res.credited_msat > 0 || res.settled) {
|
||
clearInterval(depositPoll);
|
||
depositPoll = null;
|
||
depositHash = null;
|
||
$('dep-status').textContent = 'Paid. Balance updated.';
|
||
$('dep-invoice').hidden = true;
|
||
buzz([40, 50, 40]);
|
||
await refreshBalance();
|
||
loadHistory();
|
||
}
|
||
} catch {
|
||
// Not settled yet is the normal case and arrives as an error; keep
|
||
// waiting rather than treating it as a failure.
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
async function withdraw() {
|
||
const hint = $('wd-hint');
|
||
hint.textContent = '';
|
||
hint.className = 'hint';
|
||
|
||
const bolt11 = $('wd-bolt11').value.trim();
|
||
const amount = Math.floor(Number($('wd-amt').value));
|
||
if (!bolt11.toLowerCase().startsWith('ln')) {
|
||
hint.textContent = 'That does not look like a Lightning invoice.';
|
||
hint.className = 'hint bad';
|
||
return;
|
||
}
|
||
if (!Number.isFinite(amount) || amount < 1) {
|
||
hint.textContent = 'Enter an amount in sats.';
|
||
hint.className = 'hint bad';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await api('POST', '/api/withdraw',
|
||
{ bolt11, amount_sats: amount });
|
||
hint.className = 'hint good';
|
||
hint.textContent = res.status === 'needs_approval'
|
||
? 'Queued for operator approval — large withdrawals are reviewed.'
|
||
: 'Queued. It will be paid within about fifteen seconds.';
|
||
$('wd-bolt11').value = '';
|
||
$('wd-amt').value = '';
|
||
await refreshBalance();
|
||
loadHistory();
|
||
} catch (e) {
|
||
hint.textContent = e.message;
|
||
hint.className = 'hint bad';
|
||
}
|
||
}
|
||
|
||
/* ---------------- wallet ---------------- */
|
||
|
||
async function loadHistory() {
|
||
const { entries } = await api('GET', '/api/history');
|
||
const wrap = $('history');
|
||
clear(wrap);
|
||
for (const e of entries || []) {
|
||
wrap.appendChild(el('div', { class: 'entry' },
|
||
el('span', { class: 'kind', text: e.Kind }),
|
||
el('span', {
|
||
class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'),
|
||
text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat),
|
||
}),
|
||
el('span', { class: 'after', text: sats(e.BalanceAfter) })));
|
||
}
|
||
}
|
||
|
||
async function sendSats() {
|
||
const hint = $('send-hint');
|
||
try {
|
||
const r = await api('POST', '/api/transfer', {
|
||
to_pubkey: $('to-key').value.trim(),
|
||
amount_msat: Math.round(Number($('send-amt').value) * 1000),
|
||
});
|
||
setBalance(r.balance_msat);
|
||
hint.textContent = 'Sent.';
|
||
hint.className = 'hint good';
|
||
loadHistory();
|
||
} catch (e) {
|
||
hint.textContent = e.message;
|
||
hint.className = 'hint bad';
|
||
}
|
||
}
|
||
|
||
/* ---------------- verifier ---------------- */
|
||
|
||
async function sha256(bytes) {
|
||
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
||
}
|
||
|
||
async function hmacSha256(keyBytes, msg) {
|
||
const key = await crypto.subtle.importKey(
|
||
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
|
||
}
|
||
|
||
function concat(...arrays) {
|
||
const total = arrays.reduce((n, a) => n + a.length, 0);
|
||
const out = new Uint8Array(total);
|
||
let off = 0;
|
||
for (const a of arrays) { out.set(a, off); off += a.length; }
|
||
return out;
|
||
}
|
||
|
||
function kvRow(label, value) {
|
||
return el('div', { class: 'kv' },
|
||
el('span', { text: label }),
|
||
el('code', { text: value }));
|
||
}
|
||
|
||
async function verifyRound() {
|
||
const out = $('verify-out');
|
||
clear(out);
|
||
out.appendChild(el('p', { text: 'checking…' }));
|
||
|
||
let r;
|
||
try {
|
||
r = await api('GET', '/api/verify/' + Number($('verify-id').value));
|
||
} catch (e) {
|
||
clear(out);
|
||
out.appendChild(el('p', { class: 'bad', text: e.message }));
|
||
return;
|
||
}
|
||
|
||
// 1. The revealed seed must hash to the commitment published beforehand.
|
||
const seedBytes = unhex(r.server_seed);
|
||
const seedHash = hex(await sha256(seedBytes));
|
||
const commitOK = seedHash === r.commitment;
|
||
|
||
// 2. The client seed must be the hash over participants, length-prefixed.
|
||
const parts = [];
|
||
for (const p of r.participants || []) {
|
||
const pk = unhex(p);
|
||
const len = new Uint8Array(4);
|
||
new DataView(len.buffer).setUint32(0, pk.length, false);
|
||
parts.push(len, pk);
|
||
}
|
||
const clientSeed = await sha256(concat(...parts));
|
||
const clientOK = hex(clientSeed) === r.client_seed;
|
||
|
||
// 3. The round seed follows from both, and determines the crash point.
|
||
const nonceBytes = new Uint8Array(8);
|
||
new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false);
|
||
const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes));
|
||
|
||
const allOK = commitOK && clientOK;
|
||
clear(out);
|
||
out.appendChild(el('p', {
|
||
class: allOK ? 'ok' : 'bad',
|
||
text: allOK ? '✓ This round checks out.' : '✗ Verification failed.',
|
||
}));
|
||
out.appendChild(kvRow('commitment', r.commitment));
|
||
out.appendChild(kvRow('sha256(seed)', seedHash));
|
||
out.appendChild(kvRow('client seed', r.client_seed || ''));
|
||
out.appendChild(kvRow('recomputed', hex(clientSeed)));
|
||
out.appendChild(kvRow('round seed', hex(roundSeed)));
|
||
out.appendChild(kvRow('crash point',
|
||
r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'));
|
||
out.appendChild(el('p', {
|
||
class: 'muted small',
|
||
text: 'Computed on this device. The server was asked only for the published '
|
||
+ 'values, not for its opinion.',
|
||
}));
|
||
}
|
||
|
||
/* ---------------- wiring ---------------- */
|
||
|
||
function selectTab(view) {
|
||
document.querySelectorAll('.tab').forEach((t) =>
|
||
t.classList.toggle('active', t.dataset.view === view));
|
||
document.querySelectorAll('.view').forEach((v) =>
|
||
(v.hidden = v.id !== 'view-' + view));
|
||
|
||
// The 3D loop only runs while its view is visible.
|
||
if (scene) { if (view === 'crash') scene.start(); else scene.stop(); }
|
||
if (view === 'wallet') loadHistory();
|
||
if (view === 'portfolio') renderPortfolio();
|
||
}
|
||
|
||
function setStake(v) {
|
||
stake = v;
|
||
document.querySelectorAll('.chip').forEach((c) =>
|
||
c.classList.toggle('on', Number(c.dataset.stake) === v));
|
||
document.querySelectorAll('[data-play]').forEach((b) => {
|
||
b.textContent = `Scratch for ${sats(stake)} sats`;
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
keypair = await loadOrCreateKey();
|
||
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
|
||
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
|
||
|
||
$('enter').onclick = () => signIn().catch((e) => {
|
||
$('keynote').textContent = e.message;
|
||
});
|
||
$('action').onclick = onAction;
|
||
$('sound-toggle').onclick = toggleSound;
|
||
$('send').onclick = sendSats;
|
||
$('do-verify').onclick = verifyRound;
|
||
$('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex);
|
||
|
||
document.querySelectorAll('.tab').forEach((t) =>
|
||
(t.onclick = () => selectTab(t.dataset.view)));
|
||
document.querySelectorAll('.chip').forEach((c) =>
|
||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||
|
||
$('auto-target').oninput = refreshAutoRow;
|
||
|
||
$('do-deposit').onclick = createDeposit;
|
||
$('do-withdraw').onclick = withdraw;
|
||
$('dep-copy').onclick = () =>
|
||
navigator.clipboard.writeText($('dep-bolt11').textContent);
|
||
$('dep-check').onclick = async () => {
|
||
if (!depositHash) return;
|
||
try {
|
||
const res = await api('POST', '/api/deposit/check',
|
||
{ payment_hash: depositHash });
|
||
if (res.credited_msat > 0 || res.settled) {
|
||
$('dep-status').textContent = 'Paid. Balance updated.';
|
||
$('dep-invoice').hidden = true;
|
||
depositHash = null;
|
||
await refreshBalance();
|
||
} else {
|
||
$('dep-status').textContent = 'Not settled yet — still waiting.';
|
||
}
|
||
} catch (e) {
|
||
$('dep-status').textContent = e.message;
|
||
}
|
||
};
|
||
document.querySelectorAll('[data-dep]').forEach((b) => {
|
||
b.onclick = () => { $('dep-amt').value = b.dataset.dep; };
|
||
});
|
||
document.querySelectorAll('[data-auto]').forEach((b) => {
|
||
b.onclick = () => {
|
||
$('auto-target').value = b.dataset.auto;
|
||
refreshAutoRow();
|
||
};
|
||
});
|
||
|
||
const pick = $('gamepick');
|
||
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
||
const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
|
||
b.onclick = () => {
|
||
document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
|
||
b.classList.add('on');
|
||
connect(id);
|
||
};
|
||
pick.appendChild(b);
|
||
});
|
||
|
||
setStake(stake);
|
||
renderStrip();
|
||
}
|
||
|
||
init();
|