git / moonbite-network / static/moonbite-badges.js · raw
/* MoonBite Badges — first-block celebration + earned-badge system. * * Self-contained: no libraries, no network calls. State lives in * localStorage under one key, scoped per mining address so a shared * machine can hold several miners' histories. * * Public API (all safe to call even if the DOM shelf is absent): * MoonBiteBadges.recordBlocks(sessionBlocks, chainHeight, address) * — call from the mining status poll; detects the first-ever block * (full-screen celebration) and awards later badges with a toast. * MoonBiteBadges.renderShelf(elId, address) — paint earned badges. */ (function () { 'use strict'; var STORE_KEY = 'mb_miner_profiles'; /* The block reward is consensus, not a constant to hardcode here: it halves on schedule, so the ceremony asks the server and degrades to a neutral label if the call fails. */ var REWARD_LABEL = 'MBITE'; try { fetch('/api/consensus').then(function (r) { return r.json(); }).then(function (c) { if (c && c.current_reward_coins) REWARD_LABEL = '+' + c.current_reward_coins + ' MBITE'; }).catch(function () {}); } catch (e) {} /* Badges are earned by verifiable local events only. Height tiers are * judged by the chain height when the miner's FIRST block landed — * being early is the one thing latecomers cannot copy. */ var BADGES = [ { id: 'first_bite', icon: 'I', name: 'First Bite', desc: 'Mined your first block', test: function (p) { return p.totalBlocks >= 1; } }, { id: 'genesis', icon: 'G', name: 'Genesis Miner', desc: 'First block before height 10,000', test: function (p) { return p.totalBlocks >= 1 && p.firstHeight < 10000; } }, { id: 'pioneer', icon: 'P', name: 'Pioneer', desc: 'First block before height 100,000', test: function (p) { return p.totalBlocks >= 1 && p.firstHeight < 100000; } }, { id: 'pre_halving', icon: 'H', name: 'Pre-Halving', desc: 'First block before the 1,000,000 halving', test: function (p) { return p.totalBlocks >= 1 && p.firstHeight < 1000000; } }, { id: 'ten', icon: '10', name: 'Ten Bites', desc: 'Mined 10 blocks', test: function (p) { return p.totalBlocks >= 10; } }, { id: 'hundred', icon: '100', name: 'Hundred Bites', desc: 'Mined 100 blocks', test: function (p) { return p.totalBlocks >= 100; } }, { id: 'week_streak', icon: '7', name: 'Seven Moons', desc: 'Mined on 7 different days', test: function (p) { return p.miningDays.length >= 7; } } ]; function loadAll() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch (e) { return {}; } } function saveAll(all) { localStorage.setItem(STORE_KEY, JSON.stringify(all)); } function profileFor(address) { var all = loadAll(); if (!all[address]) { all[address] = { totalBlocks: 0, sessionBase: 0, firstHeight: null, firstDate: null, miningDays: [], badges: [] }; saveAll(all); } return all[address]; } function saveProfile(address, p) { var all = loadAll(); all[address] = p; saveAll(all); } /* ---------------------------------------------------------------- record */ var lastSession = 0; function recordBlocks(sessionBlocks, chainHeight, address) { if (!address || typeof sessionBlocks !== 'number') return; var p = profileFor(address); // Session counter resets each run; only count increases. var delta = sessionBlocks - lastSession; if (sessionBlocks < lastSession) { delta = sessionBlocks; } // new session lastSession = sessionBlocks; if (delta <= 0) return; var hadNoBlocks = p.totalBlocks === 0; p.totalBlocks += delta; if (p.firstHeight === null) { p.firstHeight = Math.max(0, (chainHeight || 0) - delta + 1); p.firstDate = new Date().toISOString(); } var today = new Date().toISOString().slice(0, 10); if (p.miningDays.indexOf(today) === -1) p.miningDays.push(today); // Evaluate badges; collect the newly earned. var fresh = []; BADGES.forEach(function (b) { if (p.badges.indexOf(b.id) === -1 && b.test(p)) { p.badges.push(b.id); fresh.push(b); } }); saveProfile(address, p); if (hadNoBlocks) { /* Hand the block to /wall so it can offer a claim form there, rather than prompting for a handle mid-ceremony. */ try { localStorage.setItem('mb_last_block', JSON.stringify({ address: address, height: p.firstHeight, date: p.firstDate })); } catch (e) {} celebrateFirstBlock(p, address, fresh); } else { fresh.forEach(function (b, i) { setTimeout(function () { toast(b.icon + ' Badge earned: ' + b.name); }, i * 2200); }); } renderShelf('mbBadgeShelf', address); } /* ----------------------------------------------------------------- shelf */ function renderShelf(elId, address) { var el = document.getElementById(elId); if (!el) return; var p = address ? profileFor(address) : null; var earned = p ? p.badges : []; el.innerHTML = BADGES.map(function (b) { var got = earned.indexOf(b.id) !== -1; return '<div class="mb-badge' + (got ? ' earned' : '') + '" title="' + b.name + ' — ' + b.desc + '">' + '<span class="mb-badge-icon">' + b.icon + '</span>' + '<span class="mb-badge-name">' + b.name + '</span></div>'; }).join(''); } /* ----------------------------------------------------------------- toast */ function toast(msg) { var t = document.createElement('div'); t.className = 'mb-badge-toast'; t.textContent = msg; document.body.appendChild(t); requestAnimationFrame(function () { t.classList.add('show'); }); setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 400); }, 3500); } /* ------------------------------------------------- first-block ceremony */ function celebrateFirstBlock(p, address, freshBadges) { /* Doc 18: first and last six always visible. */ var short = address.length > 14 ? address.slice(0, 6) + '…' + address.slice(-6) : address; var dateStr = new Date(p.firstDate).toUTCString().slice(5, 16); var ov = document.createElement('div'); ov.className = 'mb-first-block-overlay'; ov.innerHTML = '<canvas class="mb-confetti"></canvas>' + '<div class="mb-first-block-card">' + '<div class="mb-fb-kicker">FIRST BLOCK FOUND</div>' + '<div class="mb-fb-amount">' + REWARD_LABEL + '</div>' + '<div class="mb-fb-line">Block #' + p.firstHeight.toLocaleString('en-US') + ' · ' + dateStr + '</div>' + '<div class="mb-fb-line mb-fb-addr">' + short + '</div>' + '<div class="mb-fb-badges">' + freshBadges.map(function (b) { return '<span class="mb-fb-badge">' + b.icon + ' ' + b.name + '</span>'; }).join('') + '</div>' + '<div class="mb-fb-quote">Proof-of-work, not proof-of-purchase. You earned this.</div>' + '<div class="mb-fb-actions">' + '<button class="mb-fb-btn" id="mbFbCert">Save certificate</button>' + '<a class="mb-fb-btn" href="/wall?claim=1" style="text-decoration:none;">Put it on the Wall</a>' + '<button class="mb-fb-btn ghost" id="mbFbClose">Keep mining →</button>' + '</div>' + '</div>'; document.body.appendChild(ov); confetti(ov.querySelector('.mb-confetti')); ov.querySelector('#mbFbClose').addEventListener('click', function () { ov.remove(); }); ov.querySelector('#mbFbCert').addEventListener('click', function () { certificate(p, short, freshBadges); }); } function confetti(canvas) { canvas.width = window.innerWidth; canvas.height = window.innerHeight; var g = canvas.getContext('2d'); var colors = ['#D9A441', '#B8863B', '#2E9E6B', '#FFFFFF']; var bits = []; for (var i = 0; i < 160; i++) { bits.push({ x: Math.random() * canvas.width, y: -20 - Math.random() * canvas.height * 0.5, s: 4 + Math.random() * 6, v: 2 + Math.random() * 4, w: Math.random() * 2 - 1, c: colors[i % colors.length], r: Math.random() * Math.PI }); } var frames = 0; (function tick() { g.clearRect(0, 0, canvas.width, canvas.height); bits.forEach(function (b) { b.y += b.v; b.x += b.w; b.r += 0.08; g.save(); g.translate(b.x, b.y); g.rotate(b.r); g.fillStyle = b.c; g.fillRect(-b.s / 2, -b.s / 2, b.s, b.s * 0.6); g.restore(); }); if (++frames < 420 && canvas.isConnected) requestAnimationFrame(tick); })(); } function certificate(p, shortAddr, freshBadges) { var cv = document.createElement('canvas'); cv.width = 1080; cv.height = 1080; var g = cv.getContext('2d'); var grad = g.createLinearGradient(0, 0, 0, 1080); grad.addColorStop(0, '#0B0D12'); grad.addColorStop(1, '#1A1E27'); g.fillStyle = grad; g.fillRect(0, 0, 1080, 1080); g.strokeStyle = 'rgba(217,164,65,0.5)'; g.lineWidth = 6; g.strokeRect(40, 40, 1000, 1000); // Bitten moon g.beginPath(); g.arc(540, 280, 130, 0, Math.PI * 2); g.fillStyle = '#D9A441'; g.fill(); g.beginPath(); g.arc(625, 235, 95, 0, Math.PI * 2); g.fillStyle = '#0B0D12'; g.fill(); g.textAlign = 'center'; g.fillStyle = 'rgba(255,255,255,0.75)'; g.font = '700 40px Arial'; g.fillText('CERTIFICATE OF FIRST BLOCK', 540, 500); g.fillStyle = '#D9A441'; g.font = '800 110px Arial'; g.fillText(REWARD_LABEL, 540, 620); g.fillStyle = '#FFFFFF'; g.font = '400 38px Arial'; g.fillText('Block #' + p.firstHeight.toLocaleString('en-US') + ' · ' + new Date(p.firstDate).toUTCString().slice(5, 16), 540, 690); g.fillStyle = 'rgba(255,255,255,0.65)'; g.font = '400 30px Arial'; g.fillText(shortAddr, 540, 740); g.fillStyle = '#B8863B'; g.font = '700 34px Arial'; g.fillText(freshBadges.map(function (b) { return b.icon + ' ' + b.name; }).join(' '), 540, 820); g.fillStyle = 'rgba(255,255,255,0.8)'; g.font = 'italic 32px Arial'; g.fillText('Proof-of-work, not proof-of-purchase.', 540, 890); g.fillStyle = 'rgba(255,255,255,0.5)'; g.font = '400 28px Arial'; g.fillText('moonbite.org/mine', 540, 960); g.fillStyle = 'rgba(255,255,255,0.35)'; g.font = '400 20px Arial'; g.fillText('MBITE has no market value and is not an investment. Verified on-chain.', 540, 1005); var a = document.createElement('a'); a.download = 'moonbite-first-block.png'; a.href = cv.toDataURL('image/png'); a.click(); } window.MoonBiteBadges = { recordBlocks: recordBlocks, renderShelf: renderShelf }; })();