feat: playable arcade — rooms, identity, client, deployment
Round length is now bounded: the multiplier follows a hyperbolic curve diverging at 60s, replacing an exponential one where a 275x crash point produced a two-and-a-half minute round. Fixes seed reveal, which silently failed every round because pgx cannot encode a fixed-size byte array as bytea. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
636
cmd/arcade/static/app.js
Normal file
636
cmd/arcade/static/app.js
Normal file
@@ -0,0 +1,636 @@
|
||||
/* Quantum Arcade client.
|
||||
*
|
||||
* Identity is an ed25519 keypair generated in the browser and kept in
|
||||
* localStorage. There is no account to create and no password to lose.
|
||||
*
|
||||
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
|
||||
* the server whether a round was fair — it checks.
|
||||
*
|
||||
* All dynamic content is inserted with textContent or built as DOM nodes.
|
||||
* Nothing that originates from another player (nicknames, keys) or from the
|
||||
* server ever reaches innerHTML. */
|
||||
|
||||
const KEY_STORAGE = 'quantum-arcade-key';
|
||||
const NAME_STORAGE = 'quantum-arcade-name';
|
||||
|
||||
let keypair = null; // { publicKeyHex, privateKey (CryptoKey) }
|
||||
let token = null;
|
||||
let nickname = '';
|
||||
let stake = 5000; // millisatoshis
|
||||
let currentGame = 'rocket';
|
||||
let socket = null;
|
||||
let snapshot = null;
|
||||
let myBet = null; // 'in' | 'out' | null
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
|
||||
|
||||
/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */
|
||||
function el(tag, attrs, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) {
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k === 'text') node.textContent = v;
|
||||
else node.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c == null) continue;
|
||||
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function clear(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
/* ---------------- identity ---------------- */
|
||||
|
||||
async function loadOrCreateKey() {
|
||||
const stored = localStorage.getItem(KEY_STORAGE);
|
||||
if (stored) {
|
||||
const jwk = JSON.parse(stored);
|
||||
const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']);
|
||||
return { privateKey: priv, publicKeyHex: jwk.qa_pub };
|
||||
}
|
||||
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
|
||||
const pubHex = hex(rawPub);
|
||||
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||||
jwk.qa_pub = pubHex;
|
||||
localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk));
|
||||
return { privateKey: kp.privateKey, publicKeyHex: pubHex };
|
||||
}
|
||||
|
||||
function hex(bytes) {
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
function unhex(s) {
|
||||
const out = new Uint8Array(s.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
nickname = ($('nickname').value || 'anon').trim().slice(0, 20);
|
||||
localStorage.setItem(NAME_STORAGE, nickname);
|
||||
|
||||
const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex });
|
||||
const sig = new Uint8Array(await crypto.subtle.sign(
|
||||
{ name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge)));
|
||||
|
||||
const res = await api('POST', '/api/auth/verify', {
|
||||
pubkey: keypair.publicKeyHex,
|
||||
signature: hex(sig),
|
||||
nickname,
|
||||
});
|
||||
token = res.token;
|
||||
|
||||
$('signin').hidden = true;
|
||||
$('app').hidden = false;
|
||||
$('balance-wrap').hidden = false;
|
||||
setBalance(res.balance_msat);
|
||||
$('pubkey').textContent = keypair.publicKeyHex;
|
||||
|
||||
connect(currentGame);
|
||||
loadScratch();
|
||||
}
|
||||
|
||||
/* ---------------- api ---------------- */
|
||||
|
||||
async function api(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
function setBalance(msat) {
|
||||
$('balance').textContent = sats(msat);
|
||||
}
|
||||
|
||||
/* ---------------- crash room ---------------- */
|
||||
|
||||
function connect(game) {
|
||||
if (socket) socket.close();
|
||||
currentGame = game;
|
||||
myBet = null;
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
|
||||
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
|
||||
socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
|
||||
}
|
||||
|
||||
function onSnapshot(s) {
|
||||
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
|
||||
snapshot = s;
|
||||
if (roundChanged) myBet = null;
|
||||
|
||||
const mult = $('multiplier');
|
||||
mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×';
|
||||
mult.className = 'multiplier';
|
||||
|
||||
$('commitment').textContent = s.commitment || '—';
|
||||
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
|
||||
|
||||
const action = $('action');
|
||||
const hint = $('hint');
|
||||
|
||||
switch (s.state) {
|
||||
case 'betting_open':
|
||||
$('state').textContent = `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||
action.textContent = myBet ? 'You are in — good luck' : 'Place bet';
|
||||
action.className = 'primary big';
|
||||
action.disabled = !!myBet;
|
||||
break;
|
||||
case 'locked':
|
||||
$('state').textContent = 'launching';
|
||||
action.textContent = 'Launching…';
|
||||
action.disabled = true;
|
||||
break;
|
||||
case 'running':
|
||||
$('state').textContent = 'in flight';
|
||||
if (myBet === 'in') {
|
||||
const payout = stake * parseFloat(s.multiplier);
|
||||
action.textContent = `Cash out ${sats(payout)}`;
|
||||
action.className = 'primary big cashout';
|
||||
action.disabled = false;
|
||||
} else {
|
||||
action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching';
|
||||
action.className = 'primary big';
|
||||
action.disabled = true;
|
||||
}
|
||||
break;
|
||||
case 'settled':
|
||||
if (s.crash_point) mult.textContent = parseFloat(s.crash_point).toFixed(2) + '×';
|
||||
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||||
$('state').textContent =
|
||||
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||
action.textContent = 'Next round';
|
||||
action.className = 'primary big';
|
||||
action.disabled = true;
|
||||
if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; }
|
||||
break;
|
||||
}
|
||||
|
||||
renderPlayers(s.players);
|
||||
draw(s);
|
||||
if (s.state === 'running') tone(parseFloat(s.multiplier));
|
||||
}
|
||||
|
||||
function renderPlayers(players) {
|
||||
const wrap = $('players');
|
||||
clear(wrap);
|
||||
for (const p of players || []) {
|
||||
const amount = p.cashed_out
|
||||
? '↑ ' + parseFloat(p.cashed_out).toFixed(2) + '×'
|
||||
: sats(p.stake_msat) + ' sats';
|
||||
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
|
||||
el('span', { class: 'who', text: p.nickname || 'anon' }),
|
||||
el('span', { class: 'amt', text: amount })));
|
||||
}
|
||||
}
|
||||
|
||||
async function onAction() {
|
||||
const hint = $('hint');
|
||||
hint.textContent = '';
|
||||
hint.className = 'hint';
|
||||
try {
|
||||
if (snapshot.state === 'betting_open' && !myBet) {
|
||||
const r = await api('POST', '/api/bet', {
|
||||
game: currentGame, stake_msat: stake, nickname,
|
||||
});
|
||||
setBalance(r.balance_msat);
|
||||
myBet = 'in';
|
||||
} else if (snapshot.state === 'running' && myBet === 'in') {
|
||||
const r = await api('POST', '/api/cashout', { game: currentGame });
|
||||
myBet = 'out';
|
||||
hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`;
|
||||
hint.className = 'hint good';
|
||||
const b = await api('GET', '/api/balance');
|
||||
setBalance(b.balance_msat);
|
||||
}
|
||||
} catch (e) {
|
||||
hint.textContent = e.message;
|
||||
hint.className = 'hint bad';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- rendering ----------------
|
||||
* Each game draws the same climb differently: a rocket fighting gravity, a
|
||||
* craft spiralling inward, or a tower stacking upward. */
|
||||
|
||||
const canvas = $('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let stars = [];
|
||||
|
||||
function sizeCanvas() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = canvas.clientHeight * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stars = Array.from({ length: 70 }, () => ({
|
||||
x: Math.random(), y: Math.random(), r: Math.random() * 1.4 + 0.3,
|
||||
}));
|
||||
}
|
||||
window.addEventListener('resize', sizeCanvas);
|
||||
|
||||
function draw(s) {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (!w || !h) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const m = parseFloat(s.multiplier) || 1;
|
||||
const crashed = s.state === 'settled';
|
||||
const progress = Math.min(1, Math.log(m) / Math.log(12));
|
||||
|
||||
// Starfield drifts downward as you climb.
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (const st of stars) {
|
||||
const y = (st.y + progress * 0.9) % 1;
|
||||
ctx.globalAlpha = 0.10 + st.r * 0.16;
|
||||
ctx.fillRect(st.x * w, y * h, st.r, st.r);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (currentGame === 'orbital') drawOrbital(w, h, progress, crashed);
|
||||
else if (currentGame === 'tower') drawTower(w, h, progress, crashed);
|
||||
else drawRocket(w, h, progress, crashed);
|
||||
}
|
||||
|
||||
function drawRocket(w, h, p, crashed) {
|
||||
const x = w * 0.5;
|
||||
const y = h * (0.88 - p * 0.66);
|
||||
const accent = crashed ? '#ff4d6d' : '#38f2e4';
|
||||
|
||||
// Exhaust plume: longer and more agitated as the climb steepens.
|
||||
const plume = 26 + p * 60;
|
||||
const g = ctx.createLinearGradient(x, y, x, y + plume);
|
||||
g.addColorStop(0, crashed ? '#ff4d6daa' : '#38f2e4cc');
|
||||
g.addColorStop(1, '#38f2e400');
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 7, y + 8);
|
||||
ctx.lineTo(x + 7, y + 8);
|
||||
ctx.lineTo(x + (Math.random() - 0.5) * 8, y + plume);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = accent;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - 18);
|
||||
ctx.lineTo(x + 9, y + 10);
|
||||
ctx.lineTo(x - 9, y + 10);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
if (crashed) {
|
||||
ctx.strokeStyle = '#ff4d6d88';
|
||||
ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const a = (i / 9) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + Math.cos(a) * 34, y + Math.sin(a) * 34);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawOrbital(w, h, p, crashed) {
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const planet = Math.min(w, h) * 0.16;
|
||||
ctx.fillStyle = '#1d2757';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, planet, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
const orbit = planet + 12 + (1 - p) * Math.min(w, h) * 0.26;
|
||||
ctx.strokeStyle = crashed ? '#ff4d6d55' : '#38f2e455';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke();
|
||||
|
||||
const a = p * Math.PI * 9;
|
||||
const x = cx + Math.cos(a) * orbit, y = cy + Math.sin(a) * orbit;
|
||||
ctx.fillStyle = crashed ? '#ff4d6d' : '#38f2e4';
|
||||
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
function drawTower(w, h, p, crashed) {
|
||||
const blocks = Math.floor(p * 16) + 1;
|
||||
const bw = w * 0.28, bh = h * 0.05;
|
||||
for (let i = 0; i < blocks; i++) {
|
||||
const sway = Math.sin(i * 0.7 + p * 6) * (i / blocks) * 22 * (crashed ? 3 : 1);
|
||||
const y = h * 0.9 - (i + 1) * bh;
|
||||
ctx.fillStyle = i === blocks - 1
|
||||
? (crashed ? '#ff4d6d' : '#38f2e4')
|
||||
: `hsl(${230 + i * 4} 45% ${22 + i}%)`;
|
||||
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- ambient sound ----------------
|
||||
* Synthesised, so it never loops and ships no audio files. */
|
||||
|
||||
let audio = null;
|
||||
|
||||
function toggleSound() {
|
||||
if (audio) {
|
||||
audio.close(); audio = null;
|
||||
$('sound-toggle').classList.remove('on');
|
||||
return;
|
||||
}
|
||||
audio = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const gain = audio.createGain();
|
||||
gain.gain.value = 0.05;
|
||||
gain.connect(audio.destination);
|
||||
|
||||
// Two detuned oscillators a fifth apart: calm, wide, and never resolving.
|
||||
[55, 82.5].forEach((f) => {
|
||||
const o = audio.createOscillator();
|
||||
o.type = 'sine';
|
||||
o.frequency.value = f;
|
||||
const lfo = audio.createOscillator();
|
||||
lfo.frequency.value = 0.05 + Math.random() * 0.06;
|
||||
const depth = audio.createGain();
|
||||
depth.gain.value = 1.5;
|
||||
lfo.connect(depth).connect(o.frequency);
|
||||
o.connect(gain);
|
||||
o.start(); lfo.start();
|
||||
});
|
||||
$('sound-toggle').classList.add('on');
|
||||
}
|
||||
|
||||
let lastTone = 0;
|
||||
function tone(mult) {
|
||||
if (!audio) return;
|
||||
const now = audio.currentTime;
|
||||
if (now - lastTone < 0.28) return;
|
||||
lastTone = now;
|
||||
const o = audio.createOscillator();
|
||||
const g = audio.createGain();
|
||||
o.type = 'triangle';
|
||||
o.frequency.value = 220 * Math.min(4, mult);
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.03, now + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
|
||||
o.connect(g).connect(audio.destination);
|
||||
o.start(now); o.stop(now + 0.3);
|
||||
}
|
||||
|
||||
/* ---------------- scratch tickets ---------------- */
|
||||
|
||||
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
|
||||
|
||||
async function loadScratch() {
|
||||
const { tickets } = await api('GET', '/api/scratch/catalog');
|
||||
const wrap = $('tickets');
|
||||
clear(wrap);
|
||||
|
||||
for (const t of tickets) {
|
||||
const grid = el('div', { class: 'grid c' + t.cells });
|
||||
for (let i = 0; i < t.cells; i++) {
|
||||
grid.appendChild(el('div', { class: 'cell', text: '?' }));
|
||||
}
|
||||
|
||||
const result = el('div', { class: 'result' });
|
||||
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
|
||||
button.dataset.play = t.id;
|
||||
button.onclick = () => playScratch(t, grid, result);
|
||||
|
||||
const table = el('table', { class: 'odds' },
|
||||
el('tr', {},
|
||||
el('th', { text: 'prize' }),
|
||||
el('th', { text: 'pays' }),
|
||||
el('th', { text: 'chance' })));
|
||||
for (const o of t.odds) {
|
||||
table.appendChild(el('tr', {},
|
||||
el('td', { text: o.tier }),
|
||||
el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }),
|
||||
el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' })));
|
||||
}
|
||||
|
||||
wrap.appendChild(el('div', { class: 'card' },
|
||||
el('h3', { text: t.name }),
|
||||
el('p', { class: 'muted small', text: t.blurb }),
|
||||
grid,
|
||||
result,
|
||||
button,
|
||||
table,
|
||||
el('p', { class: 'small' },
|
||||
'Return to player: ',
|
||||
el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }),
|
||||
'. These odds are read from the same table that generates results.')));
|
||||
}
|
||||
}
|
||||
|
||||
async function playScratch(t, grid, result) {
|
||||
result.textContent = '';
|
||||
result.className = 'result';
|
||||
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
|
||||
} catch (e) {
|
||||
result.textContent = e.message;
|
||||
return;
|
||||
}
|
||||
setBalance(out.balance_msat);
|
||||
|
||||
// Reveal cells one at a time — the outcome is already fixed, this is pacing.
|
||||
const cells = out.outcome.cells;
|
||||
const counts = {};
|
||||
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
|
||||
const winner = Object.keys(counts).find((k) => counts[k] >= 3);
|
||||
|
||||
cells.forEach((sym, i) => {
|
||||
setTimeout(() => {
|
||||
const cell = grid.children[i];
|
||||
cell.textContent = SYMBOLS[sym];
|
||||
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
|
||||
if (i === cells.length - 1) {
|
||||
const won = out.outcome.payout_msat > 0;
|
||||
result.className = 'result' + (won ? ' win' : '');
|
||||
result.textContent = won
|
||||
? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
|
||||
: 'No win this time';
|
||||
}
|
||||
}, i * 130);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- wallet ---------------- */
|
||||
|
||||
async function loadHistory() {
|
||||
const { entries } = await api('GET', '/api/history');
|
||||
const wrap = $('history');
|
||||
clear(wrap);
|
||||
for (const e of entries || []) {
|
||||
wrap.appendChild(el('div', { class: 'entry' },
|
||||
el('span', { class: 'kind', text: e.Kind }),
|
||||
el('span', {
|
||||
class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'),
|
||||
text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat),
|
||||
}),
|
||||
el('span', { class: 'after', text: sats(e.BalanceAfter) })));
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSats() {
|
||||
const hint = $('send-hint');
|
||||
try {
|
||||
const r = await api('POST', '/api/transfer', {
|
||||
to_pubkey: $('to-key').value.trim(),
|
||||
amount_msat: Math.round(Number($('send-amt').value) * 1000),
|
||||
});
|
||||
setBalance(r.balance_msat);
|
||||
hint.textContent = 'Sent.';
|
||||
hint.className = 'hint good';
|
||||
loadHistory();
|
||||
} catch (e) {
|
||||
hint.textContent = e.message;
|
||||
hint.className = 'hint bad';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- verifier ----------------
|
||||
* Recomputed here, in the browser, from published values only. */
|
||||
|
||||
async function sha256(bytes) {
|
||||
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
||||
}
|
||||
|
||||
async function hmacSha256(keyBytes, msg) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
|
||||
}
|
||||
|
||||
function concat(...arrays) {
|
||||
const total = arrays.reduce((n, a) => n + a.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const a of arrays) { out.set(a, off); off += a.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function kvRow(label, value) {
|
||||
return el('div', { class: 'kv' },
|
||||
el('span', { text: label }),
|
||||
el('code', { text: value }));
|
||||
}
|
||||
|
||||
async function verifyRound() {
|
||||
const out = $('verify-out');
|
||||
clear(out);
|
||||
out.appendChild(el('p', { text: 'checking…' }));
|
||||
|
||||
let r;
|
||||
try {
|
||||
r = await api('GET', '/api/verify/' + Number($('verify-id').value));
|
||||
} catch (e) {
|
||||
clear(out);
|
||||
out.appendChild(el('p', { class: 'bad', text: e.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. The revealed seed must hash to the commitment published beforehand.
|
||||
const seedBytes = unhex(r.server_seed);
|
||||
const seedHash = hex(await sha256(seedBytes));
|
||||
const commitOK = seedHash === r.commitment;
|
||||
|
||||
// 2. The client seed must be the hash over participants, length-prefixed.
|
||||
const parts = [];
|
||||
for (const p of r.participants || []) {
|
||||
const pk = unhex(p);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, pk.length, false);
|
||||
parts.push(len, pk);
|
||||
}
|
||||
const clientSeed = await sha256(concat(...parts));
|
||||
const clientOK = hex(clientSeed) === r.client_seed;
|
||||
|
||||
// 3. The round seed follows from both, and determines the crash point.
|
||||
const nonceBytes = new Uint8Array(8);
|
||||
new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false);
|
||||
const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes));
|
||||
|
||||
const allOK = commitOK && clientOK;
|
||||
clear(out);
|
||||
out.appendChild(el('p', {
|
||||
class: allOK ? 'ok' : 'bad',
|
||||
text: allOK ? '✓ This round checks out.' : '✗ Verification failed.',
|
||||
}));
|
||||
out.appendChild(kvRow('commitment', r.commitment));
|
||||
out.appendChild(kvRow('sha256(seed)', seedHash));
|
||||
out.appendChild(kvRow('client seed', r.client_seed || ''));
|
||||
out.appendChild(kvRow('recomputed', hex(clientSeed)));
|
||||
out.appendChild(kvRow('round seed', hex(roundSeed)));
|
||||
out.appendChild(kvRow('crash point',
|
||||
r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'));
|
||||
out.appendChild(el('p', {
|
||||
class: 'muted small',
|
||||
text: 'Computed on this device. The server was asked only for the published '
|
||||
+ 'values, not for its opinion.',
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------------- wiring ---------------- */
|
||||
|
||||
function selectTab(view) {
|
||||
document.querySelectorAll('.tab').forEach((t) =>
|
||||
t.classList.toggle('active', t.dataset.view === view));
|
||||
document.querySelectorAll('.view').forEach((v) =>
|
||||
(v.hidden = v.id !== 'view-' + view));
|
||||
if (view === 'wallet') loadHistory();
|
||||
}
|
||||
|
||||
function setStake(v) {
|
||||
stake = v;
|
||||
document.querySelectorAll('.chip').forEach((c) =>
|
||||
c.classList.toggle('on', Number(c.dataset.stake) === v));
|
||||
document.querySelectorAll('[data-play]').forEach((b) => {
|
||||
b.textContent = `Scratch for ${sats(stake)} sats`;
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
sizeCanvas();
|
||||
keypair = await loadOrCreateKey();
|
||||
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
|
||||
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
|
||||
|
||||
$('enter').onclick = () => signIn().catch((e) => {
|
||||
$('keynote').textContent = e.message;
|
||||
});
|
||||
$('action').onclick = onAction;
|
||||
$('sound-toggle').onclick = toggleSound;
|
||||
$('send').onclick = sendSats;
|
||||
$('do-verify').onclick = verifyRound;
|
||||
$('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex);
|
||||
|
||||
document.querySelectorAll('.tab').forEach((t) =>
|
||||
(t.onclick = () => selectTab(t.dataset.view)));
|
||||
document.querySelectorAll('.chip').forEach((c) =>
|
||||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||||
|
||||
const pick = $('gamepick');
|
||||
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
||||
const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
|
||||
b.onclick = () => {
|
||||
document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
connect(id);
|
||||
};
|
||||
pick.appendChild(b);
|
||||
});
|
||||
|
||||
setStake(stake);
|
||||
}
|
||||
|
||||
init();
|
||||
141
cmd/arcade/static/index.html
Normal file
141
cmd/arcade/static/index.html
Normal file
@@ -0,0 +1,141 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Quantum Arcade</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Ornate background filigree, drawn once as an SVG pattern and tiled. -->
|
||||
<svg class="filigree" aria-hidden="true">
|
||||
<defs>
|
||||
<pattern id="orn" width="120" height="120" patternUnits="userSpaceOnUse">
|
||||
<g fill="none" stroke="currentColor" stroke-width="0.6">
|
||||
<circle cx="60" cy="60" r="46"/>
|
||||
<circle cx="60" cy="60" r="30"/>
|
||||
<circle cx="60" cy="60" r="14"/>
|
||||
<path d="M60 0 L60 120 M0 60 L120 60"/>
|
||||
<path d="M17 17 L103 103 M103 17 L17 103"/>
|
||||
<path d="M60 14 q26 22 0 46 q-26-24 0-46"/>
|
||||
<path d="M14 60 q22 26 46 0 q-24-26-46 0"/>
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||
</svg>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">QUANTUM<span>ARCADE</span></div>
|
||||
<div class="balance" id="balance-wrap" hidden>
|
||||
<span class="label">balance</span>
|
||||
<span class="value" id="balance">—</span>
|
||||
</div>
|
||||
<button class="sound" id="sound-toggle" title="Ambient sound">♪</button>
|
||||
</header>
|
||||
|
||||
<!-- Sign-in: a nickname and nothing else. -->
|
||||
<section class="panel center" id="signin">
|
||||
<h1>Enter the arcade</h1>
|
||||
<p class="muted">
|
||||
No account, no email, no password. Your device generates a key that
|
||||
<em>is</em> your identity. Keep the device, keep the balance.
|
||||
</p>
|
||||
<input id="nickname" maxlength="20" placeholder="pick a name" autocomplete="off">
|
||||
<button class="primary" id="enter">Enter</button>
|
||||
<p class="fineprint" id="keynote"></p>
|
||||
</section>
|
||||
|
||||
<main id="app" hidden>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-view="crash">Crash</button>
|
||||
<button class="tab" data-view="scratch">Scratchers</button>
|
||||
<button class="tab" data-view="wallet">Wallet</button>
|
||||
<button class="tab" data-view="verify">Verify</button>
|
||||
</nav>
|
||||
|
||||
<!-- ============ CRASH ============ -->
|
||||
<section class="view" id="view-crash">
|
||||
<div class="gamepick" id="gamepick"></div>
|
||||
|
||||
<div class="stage">
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="readout">
|
||||
<div class="multiplier" id="multiplier">1.00×</div>
|
||||
<div class="state" id="state">connecting…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="stakerow">
|
||||
<button class="chip" data-stake="1000">1</button>
|
||||
<button class="chip" data-stake="5000">5</button>
|
||||
<button class="chip" data-stake="25000">25</button>
|
||||
<button class="chip" data-stake="100000">100</button>
|
||||
<span class="unit">sats</span>
|
||||
</div>
|
||||
<button class="primary big" id="action">Place bet</button>
|
||||
<div class="hint" id="hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="players" id="players"></div>
|
||||
|
||||
<details class="proof">
|
||||
<summary>Fairness for this round</summary>
|
||||
<div class="kv"><span>commitment</span><code id="commitment">—</code></div>
|
||||
<div class="kv"><span>revealed seed</span><code id="revealed">sealed until the round ends</code></div>
|
||||
<p class="muted small">
|
||||
The commitment is published before betting opens. The crash point is
|
||||
derived from that seed combined with every player's key — so it cannot
|
||||
be chosen after seeing who joined.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- ============ SCRATCH ============ -->
|
||||
<section class="view" id="view-scratch" hidden>
|
||||
<div id="tickets"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ WALLET ============ -->
|
||||
<section class="view" id="view-wallet" hidden>
|
||||
<div class="card">
|
||||
<h2>Your key</h2>
|
||||
<p class="muted small">Share this so friends can send you sats.</p>
|
||||
<code class="pubkey" id="pubkey">—</code>
|
||||
<button id="copykey">Copy</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Send sats</h2>
|
||||
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
||||
<input id="send-amt" type="number" min="1" placeholder="amount in sats">
|
||||
<button class="primary" id="send">Send</button>
|
||||
<div class="hint" id="send-hint"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Every change to your balance</h2>
|
||||
<div id="history" class="history"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ VERIFY ============ -->
|
||||
<section class="view" id="view-verify" hidden>
|
||||
<div class="card">
|
||||
<h2>Check any round</h2>
|
||||
<p class="muted small">
|
||||
Enter a round number. Your phone recomputes the outcome from the
|
||||
published seeds — it does not take the server's word for anything.
|
||||
</p>
|
||||
<input id="verify-id" type="number" min="1" placeholder="round number">
|
||||
<button class="primary" id="do-verify">Verify</button>
|
||||
<div id="verify-out" class="verify-out"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
255
cmd/arcade/static/style.css
Normal file
255
cmd/arcade/static/style.css
Normal file
@@ -0,0 +1,255 @@
|
||||
/* Quantum Arcade — obsidian and indigo, dense ornament, and vivid colour
|
||||
reserved strictly for the things that matter: the multiplier, the balance,
|
||||
and the button that takes your money out of danger. */
|
||||
|
||||
:root {
|
||||
--void: #05060d;
|
||||
--obsidian: #0a0c18;
|
||||
--indigo: #131a3a;
|
||||
--indigo-hi: #1d2757;
|
||||
--ink: #c8cbe6;
|
||||
--muted: #6a719c;
|
||||
--cyan: #38f2e4;
|
||||
--magenta: #ff3ec8;
|
||||
--gold: #ffc857;
|
||||
--danger: #ff4d6d;
|
||||
--ornament: #1a2350;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: radial-gradient(ellipse at 50% -10%, var(--indigo) 0%, var(--obsidian) 45%, var(--void) 100%);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* The ornament sits behind everything, busy but very low contrast. */
|
||||
.filigree {
|
||||
position: fixed; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
color: var(--ornament);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
body > * { position: relative; z-index: 1; }
|
||||
|
||||
/* ---------- chrome ---------- */
|
||||
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px calc(14px);
|
||||
border-bottom: 1px solid #ffffff10;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700; letter-spacing: 0.22em; font-size: 13px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.brand span { color: var(--cyan); margin-left: 6px; }
|
||||
|
||||
.balance { margin-left: auto; text-align: right; line-height: 1.1; }
|
||||
.balance .label {
|
||||
display: block; font-size: 9px; letter-spacing: 0.2em;
|
||||
text-transform: uppercase; color: var(--muted);
|
||||
}
|
||||
.balance .value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 18px; font-weight: 700; color: var(--cyan);
|
||||
text-shadow: 0 0 18px #38f2e455;
|
||||
}
|
||||
|
||||
.sound {
|
||||
background: none; border: 1px solid #ffffff20; color: var(--muted);
|
||||
width: 34px; height: 34px; border-radius: 50%; font-size: 15px;
|
||||
}
|
||||
.sound.on { color: var(--cyan); border-color: var(--cyan); }
|
||||
|
||||
/* ---------- panels ---------- */
|
||||
|
||||
.panel {
|
||||
max-width: 460px; margin: 0 auto; padding: 40px 22px;
|
||||
}
|
||||
.center { text-align: center; }
|
||||
|
||||
h1 { font-size: 26px; margin: 0 0 10px; letter-spacing: 0.02em; }
|
||||
h2 { font-size: 14px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 0 0 10px; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12.5px; }
|
||||
.fineprint { font-size: 11px; color: var(--muted); margin-top: 18px; word-break: break-all; }
|
||||
|
||||
input {
|
||||
width: 100%; padding: 13px 14px; margin: 8px 0;
|
||||
background: #ffffff08; border: 1px solid #ffffff18; border-radius: 10px;
|
||||
color: var(--ink); font-size: 16px; /* 16px stops iOS zooming on focus */
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--cyan); }
|
||||
|
||||
button {
|
||||
font: inherit; cursor: pointer; border-radius: 10px;
|
||||
border: 1px solid #ffffff20; background: #ffffff0c; color: var(--ink);
|
||||
padding: 11px 16px;
|
||||
}
|
||||
button:active { transform: translateY(1px); }
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(135deg, var(--cyan), #21b6ff);
|
||||
color: #04121a; border: none; font-weight: 700; letter-spacing: 0.04em;
|
||||
box-shadow: 0 0 26px #38f2e444;
|
||||
width: 100%; padding: 15px;
|
||||
}
|
||||
.primary.big { font-size: 17px; padding: 18px; }
|
||||
.primary.cashout {
|
||||
background: linear-gradient(135deg, var(--magenta), var(--gold));
|
||||
color: #1a0512; box-shadow: 0 0 34px #ff3ec866;
|
||||
animation: urge 900ms ease-in-out infinite;
|
||||
}
|
||||
.primary:disabled { opacity: 0.4; box-shadow: none; animation: none; }
|
||||
|
||||
@keyframes urge {
|
||||
0%, 100% { box-shadow: 0 0 26px #ff3ec855; }
|
||||
50% { box-shadow: 0 0 40px #ff3ec8aa; }
|
||||
}
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
|
||||
.tabs {
|
||||
display: flex; gap: 6px; padding: 12px 12px 0;
|
||||
max-width: 620px; margin: 0 auto;
|
||||
}
|
||||
.tab {
|
||||
flex: 1; padding: 10px 4px; font-size: 12px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; background: none; border: none; color: var(--muted);
|
||||
border-bottom: 2px solid transparent; border-radius: 0;
|
||||
}
|
||||
.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); }
|
||||
|
||||
.view { max-width: 620px; margin: 0 auto; padding: 14px 14px 60px; }
|
||||
|
||||
/* ---------- crash stage ---------- */
|
||||
|
||||
.gamepick { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||
.gamepick button {
|
||||
flex: 1; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
padding: 9px 4px; color: var(--muted);
|
||||
}
|
||||
.gamepick button.on { color: var(--cyan); border-color: var(--cyan); background: #38f2e412; }
|
||||
|
||||
.stage {
|
||||
position: relative; border-radius: 16px; overflow: hidden;
|
||||
border: 1px solid #ffffff14;
|
||||
background: linear-gradient(180deg, #070a16 0%, #05060d 100%);
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
#canvas { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.readout {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; pointer-events: none;
|
||||
}
|
||||
.multiplier {
|
||||
font-size: clamp(46px, 17vw, 88px); font-weight: 800;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
|
||||
color: var(--cyan); text-shadow: 0 0 40px #38f2e466;
|
||||
}
|
||||
.multiplier.crashed { color: var(--danger); text-shadow: 0 0 40px #ff4d6d66; }
|
||||
.multiplier.won { color: var(--gold); text-shadow: 0 0 46px #ffc85777; }
|
||||
.state {
|
||||
font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase;
|
||||
color: var(--muted); margin-top: 6px;
|
||||
}
|
||||
|
||||
.controls { margin-top: 14px; }
|
||||
.stakerow { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
|
||||
.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 12px 4px; }
|
||||
.chip.on { border-color: var(--cyan); color: var(--cyan); background: #38f2e414; }
|
||||
.unit { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; }
|
||||
|
||||
.hint { min-height: 18px; margin-top: 8px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.hint.bad { color: var(--danger); }
|
||||
.hint.good { color: var(--gold); }
|
||||
|
||||
.players { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.player {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
background: #ffffff06; border: 1px solid #ffffff10; border-radius: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.player .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.player .amt { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.player.out .amt { color: var(--gold); }
|
||||
|
||||
.proof { margin-top: 18px; }
|
||||
.proof summary {
|
||||
cursor: pointer; font-size: 11px; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--muted); padding: 8px 0;
|
||||
}
|
||||
.kv { display: flex; gap: 10px; font-size: 11px; padding: 4px 0; }
|
||||
.kv span { color: var(--muted); min-width: 96px; }
|
||||
.kv code { word-break: break-all; color: var(--ink); opacity: 0.8; }
|
||||
|
||||
/* ---------- scratch ---------- */
|
||||
|
||||
.card {
|
||||
background: #ffffff07; border: 1px solid #ffffff14;
|
||||
border-radius: 14px; padding: 16px; margin-bottom: 14px;
|
||||
}
|
||||
.card h3 { margin: 0 0 4px; font-size: 18px; }
|
||||
|
||||
.grid {
|
||||
display: grid; gap: 8px; margin: 14px 0;
|
||||
}
|
||||
.grid.c9 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid.c6 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.cell {
|
||||
aspect-ratio: 1; display: grid; place-items: center;
|
||||
font-size: 26px; border-radius: 10px;
|
||||
background: linear-gradient(140deg, var(--indigo-hi), var(--indigo));
|
||||
border: 1px solid #ffffff14;
|
||||
transition: transform 160ms ease, background 260ms ease;
|
||||
}
|
||||
.cell.revealed { background: #05060d; border-color: #ffffff22; }
|
||||
.cell.hit { border-color: var(--gold); box-shadow: 0 0 18px #ffc85755; }
|
||||
|
||||
.odds { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
|
||||
.odds th, .odds td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #ffffff10; }
|
||||
.odds th { color: var(--muted); font-weight: 500; font-size: 10px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.odds td:last-child, .odds th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.rtp { color: var(--gold); font-weight: 700; }
|
||||
|
||||
.result { text-align: center; padding: 10px 0; font-size: 16px; }
|
||||
.result.win { color: var(--gold); font-weight: 700; }
|
||||
|
||||
/* ---------- wallet ---------- */
|
||||
|
||||
.pubkey {
|
||||
display: block; word-break: break-all; font-size: 11px;
|
||||
background: #00000055; padding: 10px; border-radius: 8px; margin: 8px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.history { display: flex; flex-direction: column; gap: 3px; }
|
||||
.entry {
|
||||
display: flex; gap: 10px; font-size: 12px; padding: 8px 10px;
|
||||
background: #ffffff05; border-radius: 8px;
|
||||
}
|
||||
.entry .kind { flex: 1; color: var(--muted); }
|
||||
.entry .delta { font-variant-numeric: tabular-nums; }
|
||||
.entry .delta.pos { color: var(--cyan); }
|
||||
.entry .delta.neg { color: var(--muted); }
|
||||
.entry .after { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 74px; text-align: right; }
|
||||
|
||||
.verify-out { margin-top: 12px; font-size: 12px; }
|
||||
.verify-out .ok { color: var(--cyan); font-weight: 700; }
|
||||
.verify-out .bad { color: var(--danger); font-weight: 700; }
|
||||
.verify-out .kv code { font-size: 10px; }
|
||||
Reference in New Issue
Block a user