Pool spent: board notice + closed missions, starts/claims refused until midnight Central; no-wallet onboarding panel; landing: Before your first hunt steps + teaser player above the fold

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 19:16:43 -05:00
parent 6e2b5b7dc5
commit 4beda14950
10 changed files with 83 additions and 19 deletions
+14 -1
View File
@@ -66,6 +66,19 @@ function payable() {
} }
function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); } function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); }
// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it
// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight)
function nextResetAt(now) {
const t0 = now || Date.now(); const today = ctDay(t0);
let lo = t0, hi = t0 + 26 * 3600000;
while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; }
return hi;
}
function pool() {
const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol);
return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() };
}
function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); } function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); }
function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); } function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); }
function totals() { function totals() {
@@ -74,4 +87,4 @@ function totals() {
return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size }; return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size };
} }
module.exports = { settings, setSettings, draw, grant, completed, payable, mark, ledger, mine, totals, paidToday }; module.exports = { settings, setSettings, draw, grant, completed, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt };
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin · PolHunter</title> <title>Admin · PolHunter</title>
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/style.css?v=5"> <link rel="stylesheet" href="/style.css?v=6">
<style> <style>
label{display:block;font-size:12px;color:var(--dim);letter-spacing:.08em;text-transform:uppercase;margin:10px 0 4px} label{display:block;font-size:12px;color:var(--dim);letter-spacing:.08em;text-transform:uppercase;margin:10px 0 4px}
input,textarea,select{width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:14px var(--font)} input,textarea,select{width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:14px var(--font)}
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Your board · PolHunter</title> <title>Your board · PolHunter</title>
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<link rel="stylesheet" href="/style.css?v=5"> <link rel="stylesheet" href="/style.css?v=6">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -29,6 +29,6 @@
<footer class="foot">Rewards are for completed missions, not income. The daily pool is limited; a find made after it is spent queues and pays next. Cryptocurrency involves risk of loss.</footer> <footer class="foot">Rewards are for completed missions, not income. The daily pool is limited; a find made after it is spent queues and pays next. Cryptocurrency involves risk of loss.</footer>
</div> </div>
<script src="/app.js?v=3"></script> <script src="/app.js?v=4"></script>
</body> </body>
</html> </html>
+18 -4
View File
@@ -13,7 +13,7 @@
+ '<span class="tag' + (m.done ? ' done' : '') + '">' + (m.done ? 'Completed' : esc(m.site)) + '</span>' + '<span class="tag' + (m.done ? ' done' : '') + '">' + (m.done ? 'Completed' : esc(m.site)) + '</span>'
+ '<h3>' + esc(m.name) + '</h3><p>' + esc(m.brief) + '</p>' + '<h3>' + esc(m.name) + '</h3><p>' + esc(m.brief) + '</p>'
+ '<p style="margin-top:10px"><span class="range">' + r.minPol + ' to ' + r.maxPol + ' POL</span> <span style="color:var(--dim);font-size:12px">· ' + m.dwell + 's on the site</span></p>' + '<p style="margin-top:10px"><span class="range">' + r.minPol + ' to ' + r.maxPol + ' POL</span> <span style="color:var(--dim);font-size:12px">· ' + m.dwell + 's on the site</span></p>'
+ (m.done ? '' : isOpen + (m.done ? '' : (board.pool && board.pool.spent && !isOpen) ? '<div style="margin-top:14px"><span class="tag gold">Closed until midnight Central</span></div>' : isOpen
? '<div class="timer" id="timer">Your code appears on the site after <b>' + m.dwell + 's</b>. Keep that tab open.</div>' ? '<div class="timer" id="timer">Your code appears on the site after <b>' + m.dwell + 's</b>. Keep that tab open.</div>'
+ '<div class="codebox"><input id="code" maxlength="6" placeholder="CODE" autocomplete="off" spellcheck="false"><button class="btn pol" id="submit">Claim</button></div>' + '<div class="codebox"><input id="code" maxlength="6" placeholder="CODE" autocomplete="off" spellcheck="false"><button class="btn pol" id="submit">Claim</button></div>'
+ '<div class="msg" id="msg"></div>' + '<div class="msg" id="msg"></div>'
@@ -21,11 +21,25 @@
: '<div style="margin-top:14px"><button class="btn" data-start="' + esc(m.id) + '">Start mission</button></div>') : '<div style="margin-top:14px"><button class="btn" data-start="' + esc(m.id) + '">Start mission</button></div>')
+ '</div>'; + '</div>';
} }
const IAP = 'https://instantadpay.com';
function until(ms) { const s = Math.max(0, Math.round((ms - Date.now()) / 60000)); const h = Math.floor(s / 60), m = s % 60; return h ? h + 'h ' + m + 'm' : m + 'm'; }
// no wallet on the InstantAdPay account yet: the three steps, in place of the missions
function onboarding() {
return '<div class="notice"><b>One thing before your first hunt.</b> Drips are paid to the wallet on your InstantAdPay account, and yours has none yet.</div>'
+ '<div class="steps" style="margin-top:14px">'
+ '<div class="step"><b>Create your free InstantAdPay account.</b> Already done: you are signed in as ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + '.</div>'
+ '<div class="step"><b>Link your wallet.</b> In your InstantAdPay dashboard open <a href="' + IAP + '/my#wallet" target="_blank" rel="noopener">Wallet</a>, press connect wallet and approve. Any Polygon wallet works (MetaMask, Trust, Phantom with Polygon on).</div>'
+ '<div class="step"><b>Come back through the door.</b> Return to your dashboard and press <a href="' + IAP + '/api/my/polhunter">Open my board</a> again. Your missions unlock the moment a wallet is on the account.</div>'
+ '</div>';
}
function render() { function render() {
$('who').innerHTML = '<i></i> ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + (board.me.wallet ? '' : ' · no wallet linked'); $('who').innerHTML = '<i></i> ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + (board.me.wallet ? '' : ' · no wallet linked');
const r = board.missions[0] && board.missions[0].reward; const r = board.missions[0] && board.missions[0].reward;
if (r) $('rangeLine').textContent = 'Each find pays a random drip between ' + r.minPol + ' and ' + r.maxPol + ' POL. Your codes are yours alone.'; if (r) $('rangeLine').textContent = 'Each find pays a random drip between ' + r.minPol + ' and ' + r.maxPol + ' POL. Your codes are yours alone.';
$('missions').innerHTML = board.missions.length ? board.missions.map(card).join('') : '<div class="card"><p>No missions are open right now. Check back soon.</p></div>'; const pool = board.pool || {};
if (!board.me.wallet) $('missions').innerHTML = onboarding();
else $('missions').innerHTML = (pool.spent ? '<div class="notice" style="grid-column:1/-1"><b>Today\u2019s POL pool is spent.</b> No more claims today. Claims reopen at midnight Central' + (pool.resetsAt ? ', in ' + until(pool.resetsAt) : '') + '.</div>' : '')
+ (board.missions.length ? board.missions.map(card).join('') : '<div class="card"><p>No missions are open right now. Check back soon.</p></div>');
$('mine').innerHTML = board.drips.length ? board.drips.map(d => '<div class="row"><span>' + esc(d.site) + '</span><span class="site">' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'held, being looked at' }[d.status] || d.status) + '</span><span class="pol">+' + d.pol + ' POL</span>' + (d.tx ? '<a class="when" target="_blank" rel="noopener" href="' + explorer + '/tx/' + d.tx + '">verify ↗</a>' : '<span class="when"></span>') + '</div>').join('') $('mine').innerHTML = board.drips.length ? board.drips.map(d => '<div class="row"><span>' + esc(d.site) + '</span><span class="site">' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'held, being looked at' }[d.status] || d.status) + '</span><span class="pol">+' + d.pol + ' POL</span>' + (d.tx ? '<a class="when" target="_blank" rel="noopener" href="' + explorer + '/tx/' + d.tx + '">verify ↗</a>' : '<span class="when"></span>') + '</div>').join('')
: '<div class="row"><span class="site">Nothing yet. Your first find goes here.</span></div>'; : '<div class="row"><span class="site">Nothing yet. Your first find goes here.</span></div>';
document.querySelectorAll('[data-start]').forEach(b => b.addEventListener('click', () => start(b.dataset.start))); document.querySelectorAll('[data-start]').forEach(b => b.addEventListener('click', () => start(b.dataset.start)));
@@ -35,7 +49,7 @@
async function load() { board = await api('/api/my/board'); render(); } async function load() { board = await api('/api/my/board'); render(); }
async function start(id) { async function start(id) {
const r = await api('/api/my/start', { missionId: id }); const r = await api('/api/my/start', { missionId: id });
if (r.error) { alert(r.error); return; } if (r.error) { if (r.spent) { await load(); } else alert(r.error); return; }
open = { missionId: id, token: r.token, url: r.url, dwell: r.dwell, started: Date.now(), expires: r.expires }; open = { missionId: id, token: r.token, url: r.url, dwell: r.dwell, started: Date.now(), expires: r.expires };
window.open(r.url, '_blank', 'noopener'); window.open(r.url, '_blank', 'noopener');
render(); render();
@@ -53,7 +67,7 @@
const r = await api('/api/my/submit', { token: open.token, code }); const r = await api('/api/my/submit', { token: open.token, code });
const m = $('msg'); m.className = 'msg ' + (r.error ? 'bad' : 'ok'); m.textContent = r.error || r.message; const m = $('msg'); m.className = 'msg ' + (r.error ? 'bad' : 'ok'); m.textContent = r.error || r.message;
$('submit').disabled = false; $('submit').disabled = false;
if (!r.error) { open = null; setTimeout(load, 900); } if (!r.error || r.spent) { open = null; setTimeout(load, 900); }
} }
fetch('/api/config').then(r => r.json()).then(c => { explorer = c.explorer || explorer; }).catch(() => {}); fetch('/api/config').then(r => r.json()).then(c => { explorer = c.explorer || explorer; }).catch(() => {});
load(); load();
+22 -3
View File
@@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>PolHunter</title> <title>PolHunter</title>
<meta name="description" content="Missions across the network. Visit a site, find the thing, get a drip of POL to your wallet."> <meta name="description" content="Missions across the network. Visit a site, find the thing, get a drip of POL to your wallet.">
<link rel="stylesheet" href="/style.css?v=5"> <link rel="stylesheet" href="/style.css?v=6">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<header class="top"> <header class="top">
<a class="mark" href="/"><span class="coin"></span>PolHunter</a> <a class="mark" href="/"><span class="coin"></span>PolHunter</a>
<nav class="nav"> <nav class="nav">
<a class="btn ghost sm" href="#start">Get started</a>
<a class="btn ghost sm" href="#how">How it works</a> <a class="btn ghost sm" href="#how">How it works</a>
<a class="btn ghost sm" href="#ledger">Paid so far</a> <a class="btn ghost sm" href="#ledger">Paid so far</a>
<a class="btn sm" id="signin" href="https://instantadpay.com/api/my/polhunter">Open my board</a> <a class="btn sm" id="signin" href="https://instantadpay.com/api/my/polhunter">Open my board</a>
@@ -24,16 +25,27 @@
<source type="image/webp" srcset="/img/hero.webp"> <source type="image/webp" srcset="/img/hero.webp">
<img src="/img/hero.jpg" alt="" fetchpriority="high" decoding="async"> <img src="/img/hero.jpg" alt="" fetchpriority="high" decoding="async">
</picture> </picture>
<button class="play" id="play" type="button" aria-label="Watch the 30-second teaser"><span class="tri"></span>Watch the 30-second teaser</button>
<div class="hero-copy"> <div class="hero-copy">
<span class="pill"><i></i> Paying in POL on Polygon</span> <span class="pill"><i></i> Paying in POL on Polygon</span>
<h1>Visit it. Find it.<br><b>Get paid in POL.</b></h1> <h1>Visit it. Find it.<br><b>Get paid in POL.</b></h1>
<p>Every mission is a few minutes on one of our sites, one thing to find, and a drip of POL straight to your wallet when you find it. Your code is yours alone, and the hiding place moves.</p> <p>Every mission is a few minutes on one of our sites, one thing to find, and a drip of POL straight to your wallet when you find it. Your code is yours alone, and the hiding place moves.</p>
<a class="btn" href="https://instantadpay.com/api/my/polhunter">Start hunting</a> <a class="btn" href="https://instantadpay.com/api/my/polhunter">Start hunting</a>
<a class="btn ghost" href="#how" style="margin-left:8px">See how</a> <a class="btn ghost" href="#start" style="margin-left:8px">Before your first hunt</a>
<p id="signinNote" style="font-size:13px;color:var(--dim);margin-top:14px;display:none">Sign in with your InstantAdPay account: the button sends you through InstantAdPay and straight back here. No password, no new account.</p> <p id="signinNote" style="font-size:13px;color:var(--dim);margin-top:14px;display:none">Sign in with your InstantAdPay account: the button sends you through InstantAdPay and straight back here. No password, no new account.</p>
</div> </div>
</section> </section>
<section class="section" id="start">
<h2>Before your first hunt</h2>
<div class="sub">Drips are paid to the wallet on your InstantAdPay account. Three steps, once.</div>
<div class="steps">
<div class="step"><b>Create your free InstantAdPay account</b> at <a href="https://instantadpay.com" target="_blank" rel="noopener">instantadpay.com</a>. Email first, no wallet needed to sign up.</div>
<div class="step"><b>Link your wallet.</b> In your InstantAdPay dashboard open <a href="https://instantadpay.com/my#wallet" target="_blank" rel="noopener">Wallet</a>, press connect wallet and approve. That address is where every drip lands, on Polygon.</div>
<div class="step"><b>Open your board.</b> Press <a href="https://instantadpay.com/api/my/polhunter">Open my board</a> from your dashboard or up top. No new password: InstantAdPay signs you straight into PolHunter.</div>
</div>
</section>
<section class="section" id="how"> <section class="section" id="how">
<h2>How a hunt works</h2> <h2>How a hunt works</h2>
<div class="sub">Three steps, one code, real POL.</div> <div class="sub">Three steps, one code, real POL.</div>
@@ -54,13 +66,20 @@
<div class="adslot" data-ad="home-footer"><!-- nas: 468x60 on phones, 728x90 on wider screens --><script>(function(){var m=window.innerWidth<600;document.write('<scr'+'ipt src="https://www.networkadspace.com/showadss.php?'+(m?'w=468&h=60&n=1&bw=468&bh=60':'w=728&h=90&n=1&bw=728&bh=90')+'&c=999"></scr'+'ipt>');})();</script></div> <div class="adslot" data-ad="home-footer"><!-- nas: 468x60 on phones, 728x90 on wider screens --><script>(function(){var m=window.innerWidth<600;document.write('<scr'+'ipt src="https://www.networkadspace.com/showadss.php?'+(m?'w=468&h=60&n=1&bw=468&bh=60':'w=728&h=90&n=1&bw=728&bh=90')+'&c=999"></scr'+'ipt>');})();</script></div>
<footer class="foot"> <footer class="foot">
PolHunter rewards completed missions. Rewards are not income and are not guaranteed; the daily pool is limited and drips are random within the posted range. Cryptocurrency involves risk of loss. Hunters sign in with an InstantAdPay account; no account is created here. PolHunter rewards completed missions. Rewards are not income and are not guaranteed; the daily pool is limited, and when it is spent claims close until midnight Central. Drips are random within the posted range. Cryptocurrency involves risk of loss. Hunters sign in with an InstantAdPay account; no account is created here.
<br><a href="https://instantadpay.com">InstantAdPay</a> · <a href="/admin" rel="nofollow" style="color:var(--dim)">·</a> <br><a href="https://instantadpay.com">InstantAdPay</a> · <a href="/admin" rel="nofollow" style="color:var(--dim)">·</a>
</footer> </footer>
</div> </div>
<script> <script>
(async () => { (async () => {
if (new URLSearchParams(location.search).get('signin')) document.getElementById('signinNote').style.display = 'block'; if (new URLSearchParams(location.search).get('signin')) document.getElementById('signinNote').style.display = 'block';
// the teaser: the play button swaps the hero photograph for the video, sound on, in place
document.getElementById('play').addEventListener('click', () => {
const hero = document.querySelector('.hero'); hero.classList.add('playing');
const art = hero.querySelector('.hero-art'); const v = document.createElement('video');
v.controls = true; v.autoplay = true; v.playsInline = true; v.poster = '/video/polhunter-teaser-poster.jpg'; v.src = '/video/polhunter-teaser.mp4';
art.replaceWith(v); v.className = 'hero-video'; v.play().catch(() => {});
});
try { try {
const r = await (await fetch('/api/ledger')).json(); const r = await (await fetch('/api/ledger')).json();
const t = r.totals || {}; const t = r.totals || {};
+10 -1
View File
@@ -35,6 +35,15 @@ a{color:var(--pol-hi);text-decoration:none} a:hover{color:var(--gold-hi)}
.hero h1 b{background:linear-gradient(90deg,var(--gold-hi),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent} .hero h1 b{background:linear-gradient(90deg,var(--gold-hi),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent}
.hero p{font-size:19px;color:var(--muted);max-width:520px;margin:20px 0 26px} .hero p{font-size:19px;color:var(--muted);max-width:520px;margin:20px 0 26px}
.hero h1,.hero p{text-shadow:0 2px 24px rgba(8,6,20,.9)} .hero h1,.hero p{text-shadow:0 2px 24px rgba(8,6,20,.9)}
/* the teaser */
.play{position:absolute;right:6%;bottom:44px;z-index:3;display:inline-flex;align-items:center;gap:12px;padding:12px 20px 12px 14px;border-radius:999px;border:1px solid rgba(255,255,255,.28);background:rgba(8,6,20,.55);backdrop-filter:blur(10px);color:#fff;font:600 14px var(--font);cursor:pointer;box-shadow:0 10px 40px rgba(0,0,0,.45);transition:transform .15s,border-color .15s}
.play:hover{transform:translateY(-1px);border-color:var(--gold)}
.play .tri{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--gold-hi),var(--gold));position:relative;flex:none;box-shadow:0 0 22px rgba(243,190,67,.6)}
.play .tri::after{content:"";position:absolute;left:13px;top:10px;border-style:solid;border-width:7px 0 7px 12px;border-color:transparent transparent transparent #160a33}
.hero.playing{min-height:0;display:block;background:#000}.hero.playing::before,.hero.playing .hero-copy,.hero.playing .play{display:none}
.hero-video{display:block;width:100%;aspect-ratio:16/9;max-height:80vh;background:#000;object-fit:contain}
.notice{padding:14px 18px;border-radius:14px;background:rgba(243,190,67,.1);border:1px solid rgba(243,190,67,.4);color:var(--gold-hi);font-size:15px}
.notice b{color:var(--ink)}
.pill{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:var(--glass);border:1px solid var(--edge);color:var(--muted);font-size:12px;letter-spacing:.1em;text-transform:uppercase} .pill{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:var(--glass);border:1px solid var(--edge);color:var(--muted);font-size:12px;letter-spacing:.1em;text-transform:uppercase}
.pill i{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 10px var(--ok)} .pill i{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 10px var(--ok)}
/* cards */ /* cards */
@@ -71,6 +80,6 @@ a{color:var(--pol-hi);text-decoration:none} a:hover{color:var(--gold-hi)}
.msg{margin-top:10px;font-size:14px} .msg.ok{color:var(--ok)} .msg.bad{color:var(--bad)} .msg{margin-top:10px;font-size:14px} .msg.ok{color:var(--ok)} .msg.bad{color:var(--bad)}
.foot{padding:40px 0 30px;color:var(--dim);font-size:12.5px;line-height:1.7;border-top:1px solid var(--edge);margin-top:30px} .foot{padding:40px 0 30px;color:var(--dim);font-size:12.5px;line-height:1.7;border-top:1px solid var(--edge);margin-top:30px}
.adslot{min-height:90px;display:flex;align-items:center;justify-content:center;margin:26px 0;color:var(--dim);font-size:12px} .adslot{min-height:90px;display:flex;align-items:center;justify-content:center;margin:26px 0;color:var(--dim);font-size:12px}
@media (max-width:820px){.hero{min-height:0;margin:0 -20px 6px;border-top:0;display:block}.hero-art{position:relative;inset:auto;display:block;height:270px}.hero-art img{object-position:60% 50%}.hero::before{inset:auto 0 auto 0;top:150px;height:121px;background:linear-gradient(180deg,transparent,var(--bg))}.hero-copy{padding:6px 20px 32px}.hero p{font-size:16.5px}.adslot{overflow-x:auto;justify-content:flex-start}.row{grid-template-columns:1fr auto;row-gap:6px}.row .when{grid-column:1/-1;font-size:13px;color:var(--pol-hi)}.adslot{margin:18px -4px}} @media (max-width:820px){.hero{min-height:0;margin:0 -20px 6px;border-top:0;display:block}.hero-art{position:relative;inset:auto;display:block;height:270px}.hero-art img{object-position:60% 50%}.hero::before{inset:auto 0 auto 0;top:150px;height:121px;background:linear-gradient(180deg,transparent,var(--bg))}.hero-copy{padding:6px 20px 32px}.hero p{font-size:16.5px}.play{right:auto;bottom:auto;left:50%;top:135px;transform:translate(-50%,-50%)}.play:hover{transform:translate(-50%,-50%)}.adslot{overflow-x:auto;justify-content:flex-start}.row{grid-template-columns:1fr auto;row-gap:6px}.row .when{grid-column:1/-1;font-size:13px;color:var(--pol-hi)}.adslot{margin:18px -4px}}
/* every drip row carries its blockchain verify link; on phones it sits on its own line (Marty, 2026-09-19) */ /* every drip row carries its blockchain verify link; on phones it sits on its own line (Marty, 2026-09-19) */
.row a.when{color:var(--pol-hi)} .row a.when:hover{color:var(--gold-hi)} .row a.when{color:var(--pol-hi)} .row a.when:hover{color:var(--gold-hi)}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.
+5 -1
View File
@@ -46,6 +46,8 @@ async function notify(kind, p) {
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.'); if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error); if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error);
} }
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.';
function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; } function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; }
// ---- helpers ------------------------------------------------------------------------------- // ---- helpers -------------------------------------------------------------------------------
@@ -119,12 +121,13 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/my/board') { if (p === '/api/my/board') {
const done = new Set(rewards.mine(me.memberId).map(x => x.missionId)); const done = new Set(rewards.mine(me.memberId).map(x => x.missionId));
const wdone = me.wallet ? new Set(store.read('payouts', []).filter(x => x.wallet && x.wallet.toLowerCase() === me.wallet && x.status !== 'failed').map(x => x.missionId)) : new Set(); const wdone = me.wallet ? new Set(store.read('payouts', []).filter(x => x.wallet && x.wallet.toLowerCase() === me.wallet && x.status !== 'failed').map(x => x.missionId)) : new Set();
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn } }); return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool() });
} }
if (p === '/api/my/start' && req.method === 'POST') { if (p === '/api/my/start' && req.method === 'POST') {
const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' }); const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' });
if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' }); if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' });
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' }); if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' });
const t = missions.issue(me.memberId, m.id); const t = missions.issue(me.memberId, m.id);
// a mission URL may place the token itself with {token} (a Telegram Mini App takes it in // a mission URL may place the token itself with {token} (a Telegram Mini App takes it in
@@ -139,6 +142,7 @@ const server = http.createServer(async (req, res) => {
const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error }); const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error });
const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' }); const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' });
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g); const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' }); return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' });
} }
+11 -6
View File
@@ -37,7 +37,10 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
const tok = sso.mint({ memberId: 42, email: 'hunter@example.com', wallet: '0x' + 'ab'.repeat(20), username: 'hunter42' }); const tok = sso.mint({ memberId: 42, email: 'hunter@example.com', wallet: '0x' + 'ab'.repeat(20), username: 'hunter42' });
const a = await call('/auth?t=' + tok); eq([a.status, a.headers.get('location')], [302, '/app'], 'a good hand-off signs in and lands on the board'); const a = await call('/auth?t=' + tok); eq([a.status, a.headers.get('location')], [302, '/app'], 'a good hand-off signs in and lands on the board');
const again = await call('/auth?t=' + tok); eq(again.status, 400, 'the same hand-off token cannot be replayed'); const again = await call('/auth?t=' + tok); eq(again.status, 400, 'the same hand-off token cannot be replayed');
const board = await call('/api/my/board'); eq([board.body.me.memberId, board.body.missions.length, board.body.missions[0].done], [42, 1, false], 'board shows the mission, not done'); const board = await call('/api/my/board'); eq([board.body.me.memberId, board.body.missions.length, board.body.missions[0].done, board.body.pool.spent], [42, 1, false, false], 'board shows the mission, not done, pool open');
// a member with no wallet gets the board (the onboarding steps) but cannot start
{ const jar0 = jar; jar = ''; const nw = sso.mint({ memberId: 43, email: 'nowallet@example.com', wallet: null, username: 'nowallet' }); await call('/auth?t=' + nw);
const st0 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) }); eq([st0.status, /wallet/i.test(st0.body.error)], [400, true], 'no wallet on the account: start is refused and says why'); jar = jar0; }
// start: token + url // start: token + url
const st = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) }); const st = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) });
@@ -60,13 +63,15 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
const twice = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) }); eq(twice.status, 400, 'the same mission cannot be claimed twice'); const twice = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) }); eq(twice.status, 400, 'the same mission cannot be claimed twice');
const b2 = await call('/api/my/board'); eq([b2.body.missions[0].done, b2.body.drips.length, b2.body.drips[0].status], [true, 1, 'due'], 'board: done, one drip due (faucet off, so it waits)'); const b2 = await call('/api/my/board'); eq([b2.body.missions[0].done, b2.body.drips.length, b2.body.drips[0].status], [true, 1, 'due'], 'board: done, one drip due (faucet off, so it waits)');
// the daily cap: with the cap set below one drip, the next find queues // the daily pool: once today's committed POL reaches the cap, the board says so and new starts are
// refused until the next Central midnight (Marty, 2026-09-19)
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ dailyCapPol: 0.01 }) }); await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ dailyCapPol: 0.01 }) });
await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-2', site: 'Test site', name: 'Second', brief: 'x', url: B + '/index.html', dwell: 5 }) }); await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-2', site: 'Test site', name: 'Second', brief: 'x', url: B + '/index.html', dwell: 5 }) });
const s2 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); await sleep(5500); const bp = await call('/api/my/board'); const ct = t => new Date(t).toLocaleTimeString('en-US', { timeZone: 'America/Chicago', hour12: false });
const c2 = await (await fetch(B + '/api/embed/code?t=' + s2.body.token, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json(); eq([bp.body.pool.spent, bp.body.pool.resetsAt > Date.now(), bp.body.pool.resetsAt < Date.now() + 26 * 3600000, /^(00|24):00/.test(ct(bp.body.pool.resetsAt))], [true, true, true, true], 'board: pool spent, reset is the next Central midnight');
const q = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: s2.body.token, code: c2.code }) }); const s2 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq([s2.status, s2.body.spent], [400, true], 'a start after the pool is spent is refused');
eq([q.status, q.body.queued], [200, true], 'over the daily cap, the find is queued rather than refused'); await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ dailyCapPol: 20 }) });
const s3 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq(s3.status, 200, 'with room in the pool the start is accepted again');
// the draw is weighted low // the draw is weighted low
const rewards = require('../lib/rewards'); const draws = Array.from({ length: 4000 }, () => rewards.draw(0.05, 1)); const rewards = require('../lib/rewards'); const draws = Array.from({ length: 4000 }, () => rewards.draw(0.05, 1));