Launch build: k=3 floor-weighted draw, three missions per hunter per day, HUNT_LIVE_AT lifts the curtain and opens outbound at the launch moment, badge photos to topic + general, toasts on the board

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-20 05:04:24 -05:00
parent 84a923326e
commit 7b061480cb
10 changed files with 75 additions and 24 deletions
+12 -4
View File
@@ -15,17 +15,25 @@
const store = require('./store'); const store = require('./store');
const { ctDay } = require('./missions'); const { ctDay } = require('./missions');
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40 }; // drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor
// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per
// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool)
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 };
function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); } function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); }
function draw(min, max) { function draw(min, max, skew) {
const lo = Math.log(min), hi = Math.log(max); const lo = Math.log(min), hi = Math.log(max);
const v = Math.exp(lo + Math.random() * (hi - lo)); const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1);
const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor
const v = Math.exp(hi - u * (hi - lo));
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000; return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
} }
// prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool // prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool
// a hunter's finds today (Central), prizes excluded
function findsToday(memberId, day) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === day && p.status !== 'failed' && !p.prize).length; }
function daily(memberId) { const s = settings(); const limit = Math.max(1, Number(s.missionsPerDay) || 3); const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
function paidToday(day) { function paidToday(day) {
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize).reduce((n, p) => n + p.pol, 0); return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize).reduce((n, p) => n + p.pol, 0);
} }
@@ -88,4 +96,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, pool, nextResetAt }; module.exports = { settings, setSettings, draw, grant, completed, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday };
+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=11"> <link rel="stylesheet" href="/style.css?v=12">
<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=11"> <link rel="stylesheet" href="/style.css?v=12">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -46,6 +46,6 @@
<footer class="foot">Rewards are for completed missions, not income. The daily pool is limited; when it is spent, claims close until midnight Central. Cryptocurrency involves risk of loss.</footer> <footer class="foot">Rewards are for completed missions, not income. The daily pool is limited; when it is spent, claims close until midnight Central. Cryptocurrency involves risk of loss.</footer>
</div> </div>
<div class="modal" id="shareModal" hidden><div class="modal-card"><button class="modal-x" id="shareClose" type="button" aria-label="Close">×</button><div id="shareBody"></div></div></div> <div class="modal" id="shareModal" hidden><div class="modal-card"><button class="modal-x" id="shareClose" type="button" aria-label="Close">×</button><div id="shareBody"></div></div></div>
<script src="/app.js?v=7"></script> <script src="/app.js?v=8"></script>
</body> </body>
</html> </html>
+27 -3
View File
@@ -5,6 +5,27 @@
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const api = async (path, body) => { const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}); const j = await r.json().catch(() => ({})); if (r.status === 401) location.href = '/?signin=1'; return j; }; const api = async (path, body) => { const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}); const j = await r.json().catch(() => ({})); if (r.status === 401) location.href = '/?signin=1'; return j; };
let board = null, open = null; // open: { missionId, token, url, dwell, started } let board = null, open = null; // open: { missionId, token, url, dwell, started }
// toasts: small, stacked, gone in a few seconds
function toast(msg, kind, link) {
let host = $('toasts'); if (!host) { host = document.createElement('div'); host.id = 'toasts'; document.body.appendChild(host); }
const t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.innerHTML = msg + (link ? ' <a href="' + esc(link.href) + '" target="_blank" rel="noopener">' + esc(link.text) + '</a>' : '');
host.appendChild(t); requestAnimationFrame(() => t.classList.add('in'));
setTimeout(() => { t.classList.remove('in'); setTimeout(() => t.remove(), 400); }, kind === 'big' ? 9000 : 6000);
}
const seenPaid = new Set(); let seenLedger = null;
// a drip that flips to paid while the board is open gets a toast with its proof
function watchPaid() {
for (const d of (board.drips || [])) if (d.status === 'paid' && d.tx) { if (seenPaid.size && !seenPaid.has(d.id)) toast('\u{1F4B0} <b>' + d.pol + ' POL</b> landed in your wallet.', 'ok', { href: explorer + '/tx/' + d.tx, text: 'verify \u2197' }); seenPaid.add(d.id); }
if (!seenPaid.size) for (const d of (board.drips || [])) if (d.status === 'paid') seenPaid.add(d.id);
}
// other hunters' finds, as they happen
async function watchLedger() {
try { const r = await (await fetch('/api/ledger')).json(); const rows = r.recent || [];
if (seenLedger) { const fresh = rows.filter(x => !seenLedger.has(x.tx)).slice(0, 2); for (const x of fresh) if (!board || !board.me || x.who !== (board.me.username ? '@' + board.me.username : '#' + board.me.memberId)) toast('\u{1F3AF} ' + esc(x.who) + ' just found <b>' + x.pol + ' POL</b> on ' + esc(x.site) + '.', ''); }
seenLedger = new Set(rows.map(x => x.tx));
} catch (e) {}
}
setInterval(watchLedger, 45000); setInterval(async () => { if (board && (board.drips || []).some(d => d.status === 'due' || d.status === 'sent')) { await load(); } }, 30000);
function card(m) { function card(m) {
const isOpen = open && open.missionId === m.id; const isOpen = open && open.missionId === m.id;
@@ -13,7 +34,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 ? '' : (board.pool && board.pool.spent && !isOpen) ? '<div style="margin-top:14px"><span class="tag gold">Closed until midnight Central</span></div>' : isOpen + (m.done ? '' : (board.pool && board.pool.spent && !isOpen) ? '<div style="margin-top:14px"><span class="tag gold">Closed until midnight Central</span></div>' : (board.daily && !board.daily.left && !isOpen) ? '<div style="margin-top:14px"><span class="tag">Back tomorrow</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>'
@@ -78,6 +99,8 @@
const copy = async (el, btn) => { try { await navigator.clipboard.writeText(el.value); } catch (e) { el.select(); document.execCommand('copy'); } const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t; }, 1200); }; const copy = async (el, btn) => { try { await navigator.clipboard.writeText(el.value); } catch (e) { el.select(); document.execCommand('copy'); } const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t; }, 1200); };
$('copylink').onclick = () => copy($('mylink'), $('copylink')); $('copytext').onclick = () => copy($('sharetext'), $('copytext')); $('copylink').onclick = () => copy($('mylink'), $('copylink')); $('copytext').onclick = () => copy($('sharetext'), $('copytext'));
} }
watchPaid(); if (seenLedger === null) watchLedger();
const dl = board.daily; if (dl && $('rangeLine')) $('rangeLine').innerHTML = ($('rangeLine').textContent.replace(/\s*·\s*You have.*$/, '')) + ' \u00b7 ' + (dl.left ? 'You have <b>' + dl.left + ' of ' + dl.limit + '</b> missions left today.' : '<b>That is your ' + dl.limit + ' for today.</b> Fresh missions at midnight Central.');
$('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)));
@@ -87,7 +110,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) { if (r.spent) { await load(); } else alert(r.error); return; } if (r.error) { toast(esc(r.error), 'bad'); if (r.spent || r.dailyDone) await load(); 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();
@@ -104,9 +127,10 @@
$('submit').disabled = true; $('submit').disabled = true;
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;
if (r.error) toast(esc(r.error), 'bad'); else toast('\u{1F389} <b>Found it!</b> ' + r.pol + ' POL is on its way to your wallet.', 'big');
if (!r.error) { const a = document.createElement('a'); a.href = '#share'; a.textContent = ' Share this find \u2197'; a.style.marginLeft = '6px'; m.appendChild(a); delete $('sharetext').dataset.touched; } if (!r.error) { const a = document.createElement('a'); a.href = '#share'; a.textContent = ' Share this find \u2197'; a.style.marginLeft = '6px'; m.appendChild(a); delete $('sharetext').dataset.touched; }
// an achievement unlocked by this find: celebrate it once the board has reloaded // an achievement unlocked by this find: celebrate it once the board has reloaded
if (!r.error && r.unlocked && r.unlocked.length) { const id = r.unlocked[0]; setTimeout(async () => { await load(); shareBadge(id, true); }, 1200); } if (!r.error && r.unlocked && r.unlocked.length) { const id = r.unlocked[0]; toast('\u{1F3C6} <b>Achievement unlocked!</b>', 'big'); setTimeout(async () => { await load(); shareBadge(id, true); }, 1200); }
$('submit').disabled = false; $('submit').disabled = false;
if (!r.error || r.spent) { open = null; setTimeout(load, 900); } if (!r.error || r.spent) { open = null; setTimeout(load, 900); }
} }
+1 -1
View File
@@ -12,7 +12,7 @@
<meta property="og:url" content="https://polhunter.com/"> <meta property="og:url" content="https://polhunter.com/">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://polhunter.com/promo/polhunter-1200x630.jpg"> <meta name="twitter:image" content="https://polhunter.com/promo/polhunter-1200x630.jpg">
<link rel="stylesheet" href="/style.css?v=11"> <link rel="stylesheet" href="/style.css?v=12">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
+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>Leaderboard · PolHunter</title> <title>Leaderboard · PolHunter</title>
<meta name="description" content="The hunters with the most finds this week, this month and all time. Every drip is on Polygon."> <meta name="description" content="The hunters with the most finds this week, this month and all time. Every drip is on Polygon.">
<link rel="stylesheet" href="/style.css?v=11"> <link rel="stylesheet" href="/style.css?v=12">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
+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>Promo tools · PolHunter</title> <title>Promo tools · PolHunter</title>
<meta name="description" content="Banners, posts, swipes and the teaser video, all carrying your InstantAdPay referral."> <meta name="description" content="Banners, posts, swipes and the teaser video, all carrying your InstantAdPay referral.">
<link rel="stylesheet" href="/style.css?v=11"> <link rel="stylesheet" href="/style.css?v=12">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
+5
View File
@@ -55,6 +55,11 @@ textarea{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(--
.sharebtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} .share .card textarea{margin-top:10px} .sharebtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} .share .card textarea{margin-top:10px}
.banners{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px} .banner{background:var(--glass);border:1px solid var(--edge);border-radius:18px;padding:16px} .banner .bhead{margin-bottom:10px} .banner .bimg{padding:6px 0} .banner .bimg img{max-width:100%;height:auto;display:block;border-radius:6px} .banner textarea{margin-top:10px;font:500 12px var(--mono)} .banners{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px} .banner{background:var(--glass);border:1px solid var(--edge);border-radius:18px;padding:16px} .banner .bhead{margin-bottom:10px} .banner .bimg{padding:6px 0} .banner .bimg img{max-width:100%;height:auto;display:block;border-radius:6px} .banner textarea{margin-top:10px;font:500 12px var(--mono)}
.small{font-size:13px;color:var(--dim)} .small{font-size:13px;color:var(--dim)}
/* toasts */
#toasts{position:fixed;right:16px;bottom:16px;z-index:1200;display:flex;flex-direction:column;gap:8px;max-width:min(360px,calc(100vw - 32px));pointer-events:none}
.toast{pointer-events:auto;padding:12px 16px;border-radius:14px;background:linear-gradient(135deg,#1a0f3d,#2b1a63);border:1px solid rgba(180,140,255,.45);color:var(--ink);font-size:14px;box-shadow:0 12px 40px rgba(130,71,229,.35);opacity:0;transform:translateY(12px);transition:opacity .3s,transform .3s}
.toast.in{opacity:1;transform:none} .toast.ok{border-color:rgba(75,227,165,.5)} .toast.bad{border-color:rgba(255,122,122,.55)} .toast.big{font-size:16px;border-color:var(--gold);box-shadow:0 12px 40px rgba(243,190,67,.35)} .toast a{color:var(--gold-hi)}
@media (max-width:600px){#toasts{left:16px;right:16px;bottom:12px;max-width:none}}
/* badge cards */ /* badge cards */
.badge-strip{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px} .badge-strip{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}
.badge-a{background:var(--glass);border:1px solid var(--edge);border-radius:18px;padding:12px;text-align:center;transition:border-color .2s,transform .2s} .badge-a{background:var(--glass);border:1px solid var(--edge);border-radius:18px;padding:12px;text-align:center;transition:border-color .2s,transform .2s}
+16 -9
View File
@@ -27,9 +27,13 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const PUBLIC_DIR = path.join(__dirname, 'public'); const PUBLIC_DIR = path.join(__dirname, 'public');
badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') }); badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') });
const CURTAIN = String(process.env.CURTAIN || '').trim(); const CURTAIN = String(process.env.CURTAIN || '').trim();
// HUNT_LIVE_AT (ISO 8601): until then the curtain stays up and nothing goes out, whatever OUTBOUND
// says; from then on the curtain lifts by itself and Telegram opens. Launch: 2026-09-21T09:00-05:00.
const LIVE_AT = process.env.HUNT_LIVE_AT ? Date.parse(process.env.HUNT_LIVE_AT) : 0;
function live() { return !LIVE_AT || Date.now() >= LIVE_AT; }
const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim(); const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim();
const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, ''); const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, '');
const outbound = () => process.env.OUTBOUND === 'on'; const outbound = () => process.env.OUTBOUND === 'on' && live(); // nothing leaves before the live moment
const signupsOpen = () => process.env.SIGNUPS === 'open'; const signupsOpen = () => process.env.SIGNUPS === 'open';
store.init(DATA_DIR); store.init(DATA_DIR);
@@ -43,12 +47,12 @@ async function telegram(text) {
const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {})); const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {}));
try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; } try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; }
} }
async function telegramPhoto(file, caption) { async function telegramPhoto(file, caption, general) {
if (!outbound()) return false; // the gate if (!outbound()) return false; // the gate
const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC; const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC;
if (!tok || !chat || !file) return false; if (!tok || !chat || !file) return false;
try { try {
const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic) fd.append('message_thread_id', String(topic)); const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic && !general) fd.append('message_thread_id', String(topic));
fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg'); fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg');
const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok; const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok;
} catch (e) { return false; } } catch (e) { return false; }
@@ -58,10 +62,11 @@ async function notify(kind, p) {
if (kind === 'paid') return telegram('\u{1F3AF} <b>PolHunter</b> · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got <b>' + fmt(p.pol) + ' POL</b> · <a href="' + explorer() + '/tx/' + p.tx + '">verify</a>\n<a href="' + SITE + '">Hunt yours</a>'); if (kind === 'paid') return telegram('\u{1F3AF} <b>PolHunter</b> · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got <b>' + fmt(p.pol) + ' POL</b> · <a href="' + explorer() + '/tx/' + p.tx + '">verify</a>\n<a href="' + SITE + '">Hunt yours</a>');
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);
if (kind === 'badge') { const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} <b>PolHunter</b> \u00b7 <b>' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + '</b> unlocked <b>' + b.name + '</b>: ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; return file ? telegramPhoto(file, cap) : telegram(cap); } if (kind === 'badge') { const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} <b>PolHunter</b> \u00b7 <b>' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + '</b> unlocked <b>' + b.name + '</b>: ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; if (!file) return telegram(cap); const ok = await telegramPhoto(file, cap); if (String(process.env.HUNT_TG_BADGE_GENERAL || 'on') === 'on') await telegramPhoto(file, cap, true); return ok; }
if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); } if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); }
} }
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central // Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
const dailyMsg = d => 'That is your ' + ['', 'one', 'two', 'three', 'four', 'five'][d.limit] + ' for today. Fresh missions at midnight Central.';
const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.'; const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.';
function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; } function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; }
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'; }
@@ -104,7 +109,7 @@ const ip = req => String(req.headers['x-forwarded-for'] || req.socket.remoteAddr
const CURTAIN_PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>Coming soon</title><style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}body{display:flex;align-items:center;justify-content:center;padding:24px;background:#0d1117;color:#e6edf3;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}.card{max-width:420px;text-align:center}h1{font-size:clamp(28px,7vw,44px);font-weight:700;letter-spacing:-.5px;margin-bottom:14px}p{color:#8b949e}</style></head><body><div class="card"><h1>Coming soon</h1><p>This site is still being built.</p></div></body></html>`; const CURTAIN_PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>Coming soon</title><style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}body{display:flex;align-items:center;justify-content:center;padding:24px;background:#0d1117;color:#e6edf3;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}.card{max-width:420px;text-align:center}h1{font-size:clamp(28px,7vw,44px);font-weight:700;letter-spacing:-.5px;margin-bottom:14px}p{color:#8b949e}</style></head><body><div class="card"><h1>Coming soon</h1><p>This site is still being built.</p></div></body></html>`;
function curtained(req, res, u) { function curtained(req, res, u) {
if (!CURTAIN) return false; if (!CURTAIN || (LIVE_AT && live())) return false; // the launch moment lifts the curtain
if (u.searchParams.get('k') === CURTAIN) { u.searchParams.delete('k'); res.writeHead(302, { 'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure', Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' }); res.end(); return true; } if (u.searchParams.get('k') === CURTAIN) { u.searchParams.delete('k'); res.writeHead(302, { 'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure', Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' }); res.end(); return true; }
const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || ''); const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || '');
if (m && decodeURIComponent(m[1]) === CURTAIN) return false; if (m && decodeURIComponent(m[1]) === CURTAIN) return false;
@@ -117,7 +122,7 @@ const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief,
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
try { try {
const u = new URL(req.url, 'http://x'); const p = u.pathname; const u = new URL(req.url, 'http://x'); const p = u.pathname;
if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null }); if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN && !(LIVE_AT && live()), live: live(), liveAt: LIVE_AT ? new Date(LIVE_AT).toISOString() : null, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null });
// the embed talks to us from the mission sites: it must work through the curtain, and it must // the embed talks to us from the mission sites: it must work through the curtain, and it must
// answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first) // answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first)
@@ -144,7 +149,7 @@ const server = http.createServer(async (req, res) => {
const share = encodeURIComponent(title + ' ' + url); const share = encodeURIComponent(title + ' ' + url);
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(title) + '</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + url + '"><meta name="robots" content="noindex">' const html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(title) + '</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + url + '"><meta name="robots" content="noindex">'
+ '<meta property="og:type" content="website"><meta property="og:site_name" content="PolHunter"><meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:url" content="' + url + '"><meta property="og:image" content="' + img + '"><meta property="og:image:width" content="1080"><meta property="og:image:height" content="1080">' + '<meta property="og:type" content="website"><meta property="og:site_name" content="PolHunter"><meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:url" content="' + url + '"><meta property="og:image" content="' + img + '"><meta property="og:image:width" content="1080"><meta property="og:image:height" content="1080">'
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(title) + '"><meta name="twitter:description" content="' + esc(desc) + '"><meta name="twitter:image" content="' + img + '"><link rel="stylesheet" href="/style.css?v=11"></head>' + '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(title) + '"><meta name="twitter:description" content="' + esc(desc) + '"><meta name="twitter:image" content="' + img + '"><link rel="stylesheet" href="/style.css?v=12"></head>'
+ '<body><div class="wrap"><header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter</a><nav class="nav"><a class="btn ghost sm" href="/leaders">Leaderboard</a><a class="btn sm" href="' + ref + '">Hunt yours</a></nav></header>' + '<body><div class="wrap"><header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter</a><nav class="nav"><a class="btn ghost sm" href="/leaders">Leaderboard</a><a class="btn sm" href="' + ref + '">Hunt yours</a></nav></header>'
+ '<div class="bp"><img src="' + img + '" alt="' + esc(b.name) + ' badge for ' + esc(m.who) + '"><h1>' + esc(m.who) + ' unlocked <em>' + esc(b.name) + '</em></h1><p>' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.</p>' + '<div class="bp"><img src="' + img + '" alt="' + esc(b.name) + ' badge for ' + esc(m.who) + '"><h1>' + esc(m.who) + ' unlocked <em>' + esc(b.name) + '</em></h1><p>' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.</p>'
+ '<a class="btn" href="' + ref + '">Hunt yours</a><div class="sharebtns" style="justify-content:center;margin-top:16px"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + share + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(title) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + share + '">WhatsApp</a></div>' + '<a class="btn" href="' + ref + '">Hunt yours</a><div class="sharebtns" style="justify-content:center;margin-top:16px"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + share + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(title) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + share + '">WhatsApp</a></div>'
@@ -192,7 +197,7 @@ 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 }, pool: rewards.pool(), 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(), daily: rewards.daily(me.memberId),
badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') }, badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) } }); share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) } });
} }
@@ -201,6 +206,7 @@ const server = http.createServer(async (req, res) => {
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 }); } { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.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
@@ -216,6 +222,7 @@ const server = http.createServer(async (req, res) => {
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 pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
const before = new Set(social.badgesFor(me.memberId).map(b => b.id)); const before = new Set(social.badgesFor(me.memberId).map(b => b.id));
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);
// achievements unlocked by this find: told to the board, posted to Telegram (gated) // achievements unlocked by this find: told to the board, posted to Telegram (gated)
@@ -240,7 +247,7 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { ok: true, missions: missions.list() }); return json(res, 200, { ok: true, missions: missions.list() });
} }
if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); } if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); }
if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); }
// award a week by hand (its Monday key); already-awarded weeks are skipped // award a week by hand (its Monday key); already-awarded weeks are skipped
if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); } if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); }
if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); } if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); }
+9 -2
View File
@@ -74,8 +74,15 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
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'); 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: 6000 }, () => rewards.draw(0.05, 1, 3));
const median = draws.sort((x, y) => x - y)[2000]; eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.35], [true, true], 'the draw stays in range and its median sits low (' + median + ')'); const median = draws.sort((x, y) => x - y)[3000]; const mean = draws.reduce((a, b) => a + b, 0) / draws.length;
eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.12, mean < 0.16, draws.some(d => d > 0.5)], [true, true, true, true], 'the k=3 draw stays in range, median ~0.09, mean ~0.13, a big drip still happens (' + median.toFixed(3) + '/' + mean.toFixed(3) + ')');
// three missions a day: with the limit set to 1, the second start of the day is refused and the board says so
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ missionsPerDay: 1 }) });
const bdl = await call('/api/my/board'); eq([bdl.body.daily.limit, bdl.body.daily.done, bdl.body.daily.left], [1, 1, 0], 'board: daily limit, done and left');
const sdl = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq([sdl.status, sdl.body.dailyDone, /for today/.test(sdl.body.error)], [400, true, true], 'past the daily limit a start is refused with the midnight message');
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ missionsPerDay: 3 }) });
const h2 = await call('/health'); eq([h2.body.live, h2.body.liveAt], [true, null], 'no HUNT_LIVE_AT: live now');
// social: the find is on the leaderboard with a badge, the board carries rank and a share link, // social: the find is on the leaderboard with a badge, the board carries rank and a share link,
// and a share link visit sets the referral cookie that turns sign-ups into that member's IAP join link // and a share link visit sets the referral cookie that turns sign-ups into that member's IAP join link