Badge cards: seven AI artworks for the hunting achievements, the hunter's name stamped on the ribbon server-side (ffmpeg), public share pages /b/<who>/<id> with OG cards, badge strip + share modal + unlock celebration on the board, Telegram photo post gated by OUTBOUND

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 20:31:20 -05:00
parent 6338af9efe
commit 8eecc7c0cb
19 changed files with 156 additions and 14 deletions
+2
View File
@@ -1,5 +1,7 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# badge cards: the hunter's name is drawn onto the artwork with ffmpeg
RUN apk add --no-cache ffmpeg ttf-dejavu
COPY package.json ./ COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund RUN npm install --omit=dev --no-audit --no-fund
COPY . . COPY . .
+47
View File
@@ -0,0 +1,47 @@
// Badge cards: the AI artwork for each achievement (public/badges/badge-<id>.jpg, 1080x1080, a blank
// gold ribbon at ribbonY) with the hunter's name drawn onto the ribbon by ffmpeg on the server, so the
// card looks the same whatever device unlocked it (the IAP lesson: phones ran out of canvas memory).
// Rendered cards are cached on the volume: DATA_DIR/badges/<who>-<id>.jpg.
'use strict';
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
let PUBLIC_DIR = null, CACHE_DIR = null, FONT;
// per-badge ribbon position (fraction of height); tuned to the artwork
const ART = { first: 0.734, hunter: 0.753, tracker: 0.715, sweep: 0.689, lucky: 0.753, streak: 0.702, top: 0.725 };
function init(opts) { PUBLIC_DIR = opts.publicDir; CACHE_DIR = path.join(opts.dataDir, 'badges'); try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) {} }
function artFile(id) { return path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'); }
function hasArt(id) { return fs.existsSync(artFile(id)); }
function font() {
if (FONT !== undefined) return FONT;
FONT = process.env.BADGE_FONT || null; if (FONT) return FONT;
const walk = d => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return null; } for (const e of ents) { const f = path.join(d, e.name); if (e.isDirectory()) { const r = walk(f); if (r) return r; } else if (/bold\.ttf$/i.test(e.name) || /Bold\.ttf$/.test(e.name)) return f; } return null; };
for (const d of ['/usr/share/fonts', '/usr/local/share/fonts']) { const r = walk(d); if (r) { FONT = r; break; } }
if (!FONT && process.platform === 'win32' && fs.existsSync('C:/Windows/Fonts/arialbd.ttf')) FONT = 'C:/Windows/Fonts/arialbd.ttf';
return FONT;
}
const ffText = t => String(t).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:').replace(/%/g, '%%');
const safe = s => String(s || '').toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 40);
// the card for one hunter + badge: cached JPEG path, or null when ffmpeg / font / art is missing
function render(id, who) {
return new Promise(resolve => {
if (!(id in ART) || !hasArt(id)) return resolve(null);
const F = font(); if (!F) return resolve(null);
const out = path.join(CACHE_DIR, safe(who) + '-' + id + '.jpg');
if (fs.existsSync(out)) return resolve(out);
const name = String(who || '').slice(0, 28);
const winPath = /:/.test(F); const fontFile = winPath ? path.basename(F) : F;
// 1080-wide art: 58px bold gold text with a dark outline, centred on the ribbon
const vf = 'drawtext=fontfile=' + fontFile + ":text='" + ffText(name) + "':fontcolor=#1a0f3d:fontsize=60:borderw=3:bordercolor=0xfff1c2@0.85:x=(w-text_w)/2:y=" + ART[id] + '*h-text_h/2';
const p = spawn('ffmpeg', ['-v', 'error', '-y', '-i', artFile(id), '-vf', vf, '-frames:v', '1', '-q:v', '3', out], { stdio: ['ignore', 'ignore', 'pipe'], cwd: winPath ? path.dirname(F) : undefined });
let err = ''; p.stderr.on('data', c => { err += c; });
p.on('error', () => resolve(null));
p.on('close', code => { if (code === 0 && fs.existsSync(out)) resolve(out); else { console.error('badge render failed', code, err.slice(0, 200)); resolve(null); } });
});
}
function available() { return new Promise(resolve => { const p = spawn('ffmpeg', ['-version'], { stdio: 'ignore' }); p.on('error', () => resolve(false)); p.on('close', c => resolve(c === 0 && !!font())); }); }
module.exports = { init, render, hasArt, available, safe, ART };
+9 -1
View File
@@ -105,6 +105,14 @@ function rankOf(memberId, period) {
// the member's share link: PolHunter's landing with their IAP referral, which the landing turns into // the member's share link: PolHunter's landing with their IAP referral, which the landing turns into
// their instantadpay.com/join/<ref> link for everyone who signs up from it // their instantadpay.com/join/<ref> link for everyone who signs up from it
// the public name used in share URLs: the IAP username, else m<memberId>; and back again
function nameOf(me) { return me.username ? String(me.username).toLowerCase() : 'm' + me.memberId; }
function memberByName(who) {
const w = String(who || '').toLowerCase(); if (!w) return null;
const all = store.read('payouts', []);
const p = all.find(x => (x.username && String(x.username).toLowerCase() === w) || 'm' + x.memberId === w);
return p ? { memberId: p.memberId, username: p.username || null, who: p.username ? '@' + p.username : '#' + p.memberId } : null;
}
function shareLink(site, me) { const ref = me.username || me.memberId; return site + '/?r=' + encodeURIComponent(String(ref)); } function shareLink(site, me) { const ref = me.username || me.memberId; return site + '/?r=' + encodeURIComponent(String(ref)); }
module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink, weekOf, weekRows, awardWeek, awardDue, prizes, prizeRules }; module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink, weekOf, weekRows, awardWeek, awardDue, prizes, prizeRules, nameOf, memberByName };
+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=9"> <link rel="stylesheet" href="/style.css?v=10">
<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)}
+9 -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=9"> <link rel="stylesheet" href="/style.css?v=10">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -21,6 +21,12 @@
<div class="grid" id="missions"><div class="card"><p>Loading your board…</p></div></div> <div class="grid" id="missions"><div class="card"><p>Loading your board…</p></div></div>
</section> </section>
<section class="section" id="badgesSec">
<h2>Your badges</h2>
<div class="sub">Earned from your finds. Share one and the card carries your name and your link.</div>
<div class="badge-strip" id="badgeStrip"></div>
</section>
<div class="adslot" data-ad="board"><!-- 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="board"><!-- 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>
<section class="section"> <section class="section">
@@ -39,6 +45,7 @@
<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>
<script src="/app.js?v=6"></script> <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>
</body> </body>
</html> </html>
+21
View File
@@ -32,7 +32,26 @@
+ '<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 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>'; + '</div>';
} }
// badge cards: art, lock state, share
function badgeStrip() {
const el = $('badgeStrip'); if (!el || !board.badgeCards) return;
el.innerHTML = board.badgeCards.map(b => '<div class="badge-a' + (b.earned ? '' : ' locked') + '"><img src="' + b.art + '" alt="' + esc(b.name) + '" loading="lazy"><div class="bl">' + b.icon + ' ' + esc(b.name) + '</div><div class="bs">' + esc(b.why) + '</div>' + (b.earned ? '<button class="btn pol sm" data-share="' + b.id + '">Share</button>' : '<div class="bs">\u{1F512} locked</div>') + '</div>').join('');
el.querySelectorAll('[data-share]').forEach(x => x.addEventListener('click', () => shareBadge(x.dataset.share, false)));
}
function shareBadge(id, unlocked) {
const b = (board.badgeCards || []).find(x => x.id === id); if (!b || !b.earned) return;
const text = (unlocked ? 'Achievement unlocked on PolHunter: ' : 'I earned ') + b.name + ' (' + b.why + ') on PolHunter. Visit a site, find your code, get a drip of POL to your wallet. ' + b.page;
const T = encodeURIComponent(text), U = encodeURIComponent(b.page);
$('shareBody').innerHTML = '<div class="share-hd">' + (unlocked ? '<span class="tag gold">Achievement unlocked</span>' : '') + '<h3>' + b.icon + ' ' + esc(b.name) + '</h3><p class="site">' + esc(b.why) + '</p></div>'
+ '<img class="share-img" src="' + b.image + '?t=' + Date.now() + '" alt="' + esc(b.name) + '">'
+ '<div class="sharebtns" style="justify-content:center"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + T + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + U + '&text=' + encodeURIComponent(text.replace(b.page, '').trim()) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + U + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + T + '">WhatsApp</a><a class="btn ghost sm" href="' + b.image + '" download="polhunter-' + b.id + '.jpg">Save image</a><button class="btn pol sm" id="shareCopy">Copy link</button></div>'
+ '<p class="small" style="margin-top:10px;text-align:center">The card carries your name; the link carries your referral.</p>';
$('shareModal').hidden = false;
$('shareCopy').onclick = async () => { try { await navigator.clipboard.writeText(b.page); } catch (e) {} $('shareCopy').textContent = 'Copied'; setTimeout(() => { $('shareCopy').textContent = 'Copy link'; }, 1200); };
}
document.addEventListener('click', e => { if (e.target.id === 'shareClose' || e.target.id === 'shareModal') $('shareModal').hidden = true; });
function render() { function render() {
badgeStrip();
$('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.';
@@ -86,6 +105,8 @@
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) { 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
if (!r.error && r.unlocked && r.unlocked.length) { const id = r.unlocked[0]; 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); }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+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=9"> <link rel="stylesheet" href="/style.css?v=10">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
+3 -3
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=9"> <link rel="stylesheet" href="/style.css?v=10">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -37,7 +37,7 @@
<section class="section"> <section class="section">
<h2>Badges</h2> <h2>Badges</h2>
<div class="sub">Earned automatically from your finds. They show next to your name up there and on your board.</div> <div class="sub">Earned automatically from your finds. They show next to your name up there and on your board.</div>
<div class="grid" id="badges"></div> <div class="badge-strip" id="badges"></div>
</section> </section>
<div class="adslot" data-ad="leaders"><!-- 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="leaders"><!-- 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>
@@ -63,7 +63,7 @@
if (pz.last && pz.last.winners.length) { document.getElementById('winners').style.display = 'block'; document.getElementById('winsub').textContent = 'Week of ' + pz.last.week + '.'; document.getElementById('winrows').innerHTML = pz.last.winners.map(w => '<div class="row lb"><span><b class="num">' + medal(w.rank) + '</b> ' + esc(w.who) + '</span><span class="site">' + w.finds + ' finds</span><span class="pol">+' + w.prizePol + ' POL prize</span></div>').join(''); } if (pz.last && pz.last.winners.length) { document.getElementById('winners').style.display = 'block'; document.getElementById('winsub').textContent = 'Week of ' + pz.last.week + '.'; document.getElementById('winrows').innerHTML = pz.last.winners.map(w => '<div class="row lb"><span><b class="num">' + medal(w.rank) + '</b> ' + esc(w.who) + '</span><span class="site">' + w.finds + ' finds</span><span class="pol">+' + w.prizePol + ' POL prize</span></div>').join(''); }
} catch (e) {} } catch (e) {}
const b = await (await fetch('/api/badges')).json(); const b = await (await fetch('/api/badges')).json();
document.getElementById('badges').innerHTML = b.badges.map(x => '<div class="card"><div style="font-size:34px">' + x.icon + '</div><h3>' + esc(x.name) + '</h3><p>' + esc(x.why) + '</p>' + (me && me.badges && me.badges.some(y => y.id === x.id) ? '<p style="margin-top:8px"><span class="tag done">Earned</span></p>' : '') + '</div>').join(''); document.getElementById('badges').innerHTML = b.badges.map(x => { const got = me && me.badges && me.badges.some(y => y.id === x.id); return '<div class="badge-a' + (got ? '' : ' locked') + '"><img src="' + x.art + '" alt="' + esc(x.name) + '" loading="lazy"><div class="bl">' + x.icon + ' ' + esc(x.name) + '</div><div class="bs">' + esc(x.why) + '</div>' + (got ? '<p style="margin-top:8px"><span class="tag done">Earned</span></p>' : '') + '</div>'; }).join('');
show('week'); show('week');
})(); })();
</script> </script>
+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=9"> <link rel="stylesheet" href="/style.css?v=10">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
+11
View File
@@ -55,6 +55,17 @@ 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)}
/* badge cards */
.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:hover{border-color:var(--edge-hi);transform:translateY(-2px)} .badge-a img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:12px;display:block}
.badge-a .bl{font-weight:800;margin-top:10px;font-size:14px} .badge-a .bs{font-size:12px;color:var(--muted);margin:4px 0 8px}
.badge-a.locked img{filter:grayscale(1) brightness(.4)} .badge-a.locked .bl{color:var(--dim)}
.modal{position:fixed;inset:0;z-index:1000;background:rgba(8,6,20,.82);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;padding:20px;overflow:auto}
.modal-card{position:relative;width:100%;max-width:520px;background:linear-gradient(180deg,#171036,#0e0a22);border:1px solid var(--edge-hi);border-radius:22px;padding:22px;box-shadow:0 30px 90px rgba(0,0,0,.6)}
.modal-x{position:absolute;right:12px;top:10px;width:36px;height:36px;border-radius:50%;border:1px solid var(--edge);background:var(--glass);color:var(--ink);font-size:22px;cursor:pointer}
.share-hd{text-align:center;margin-bottom:12px} .share-hd h3{font-size:22px;font-weight:800;margin-top:8px} .share-img{width:100%;border-radius:14px;display:block;margin:0 auto 14px;box-shadow:0 16px 50px rgba(130,71,229,.35)}
.bp{max-width:560px;margin:0 auto;text-align:center;padding:30px 0 50px} .bp img{width:100%;max-width:520px;border-radius:18px;border:1px solid var(--edge);box-shadow:0 20px 60px rgba(0,0,0,.5)} .bp h1{font-size:clamp(24px,5vw,34px);margin:22px 0 8px;font-weight:800;letter-spacing:-.6px} .bp h1 em{font-style:normal;background:linear-gradient(90deg,var(--gold-hi),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent} .bp p{color:var(--muted);margin:0 auto 20px;max-width:46ch}
.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 */
+41 -3
View File
@@ -19,11 +19,13 @@ const sso = require('./lib/sso');
const missions = require('./lib/missions'); const missions = require('./lib/missions');
const rewards = require('./lib/rewards'); const rewards = require('./lib/rewards');
const social = require('./lib/social'); const social = require('./lib/social');
const badge = require('./lib/badge');
const faucet = require('./lib/faucet'); const faucet = require('./lib/faucet');
const PORT = Number(process.env.PORT || 3000); const PORT = Number(process.env.PORT || 3000);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); 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') });
const CURTAIN = String(process.env.CURTAIN || '').trim(); const CURTAIN = String(process.env.CURTAIN || '').trim();
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(/\/+$/, '');
@@ -41,11 +43,22 @@ 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) {
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;
if (!tok || !chat || !file) return false;
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));
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;
} catch (e) { return false; }
}
const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 }); const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 });
async function notify(kind, p) { 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 === '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
@@ -117,6 +130,27 @@ const server = http.createServer(async (req, res) => {
return json(res, r.error ? 403 : 200, r, cors); return json(res, r.error ? 403 : 200, r, cors);
} }
if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' }); if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' });
// badge cards and their share pages are posted around the web: they pass the curtain
if (/^\/badges\/badge-[a-z]+\.jpg$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
{ const bm = /^\/badge-img\/([a-z0-9_.-]{1,40})\/([a-z]+)\.jpg$/.exec(p) || /^\/b\/([a-z0-9_.-]{1,40})\/([a-z]+)$/.exec(p);
if (bm) {
const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id);
if (!m || !b || !social.badgesFor(m.memberId).some(x => x.id === id)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
if (p.startsWith('/badge-img/')) { const file = await badge.render(id, m.username || '#' + m.memberId); return sendFile(res, file || path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'), { 'Cache-Control': 'public, max-age=3600' }); }
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const url = SITE + '/b/' + who + '/' + id, img = SITE + '/badge-img/' + who + '/' + id + '.jpg', ref = SITE + '/?r=' + encodeURIComponent(m.username || m.memberId);
const title = m.who + ' unlocked ' + b.name + ' on PolHunter', desc = b.name + ': ' + b.why + '. PolHunter pays random drips of POL for finding your code on our sites, on chain, with a link to prove it.';
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">'
+ '<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=10"></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>'
+ '<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>'
+ '<p class="small" style="margin-top:26px">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</p></div></div></body></html>';
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(html);
}
}
// the coin on the code pill, fetched by mission-site visitors who hold no curtain pass // the coin on the code pill, fetched by mission-site visitors who hold no curtain pass
if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' }); if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
@@ -145,7 +179,7 @@ const server = http.createServer(async (req, res) => {
// ---- public // ---- public
if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); } if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); }
if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); } if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); }
if (p === '/api/badges') return json(res, 200, { badges: social.BADGES }); if (p === '/api/badges') return json(res, 200, { badges: social.BADGES.map(b => Object.assign({ art: '/badges/badge-' + b.id + '.jpg' }, b)) });
if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' }); if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' });
if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html')); if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html')); if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
@@ -158,7 +192,7 @@ const server = http.createServer(async (req, res) => {
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(),
badges: social.badgesFor(me.memberId), 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)) } });
} }
if (p === '/api/my/start' && req.method === 'POST') { if (p === '/api/my/start' && req.method === 'POST') {
@@ -181,8 +215,12 @@ 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 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);
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.' }); // achievements unlocked by this find: told to the board, posted to Telegram (gated)
const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id));
for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {});
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, unlocked: unlocked.map(b => b.id), 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, 404, { error: 'No such call.' }); return json(res, 404, { error: 'No such call.' });
} }
+10 -2
View File
@@ -59,7 +59,7 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
// claim // claim
const wrong = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: 'ZZZZZZ' }) }); eq(wrong.status, 400, 'a wrong code is refused'); const wrong = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: 'ZZZZZZ' }) }); eq(wrong.status, 400, 'a wrong code is refused');
const ok = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) }); const ok = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) });
eq([ok.status, ok.body.pol >= 0.05 && ok.body.pol <= 1, ok.body.queued], [200, true, false], 'the right code pays a drip in range, not queued'); eq([ok.status, ok.body.pol >= 0.05 && ok.body.pol <= 1, ok.body.queued, ok.body.unlocked], [200, true, false, ['first', 'sweep']], 'the right code pays a drip in range, not queued, and unlocks First Find (and Full Sweep: the test has one site)');
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)');
@@ -80,7 +80,15 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
// 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
const lb = await call('/api/leaders?period=all'); eq([lb.status, lb.body.rows.length >= 1, lb.body.rows[0].memberId, lb.body.rows[0].finds >= 1, lb.body.rows[0].badges.length >= 1], [200, true, 42, true, true], 'leaderboard: hunter 42 leads all time with a badge'); const lb = await call('/api/leaders?period=all'); eq([lb.status, lb.body.rows.length >= 1, lb.body.rows[0].memberId, lb.body.rows[0].finds >= 1, lb.body.rows[0].badges.length >= 1], [200, true, 42, true, true], 'leaderboard: hunter 42 leads all time with a badge');
const bd = await call('/api/badges'); eq(bd.body.badges.length, 7, 'seven badges are defined'); const bd = await call('/api/badges'); eq([bd.body.badges.length, bd.body.badges[0].art], [7, '/badges/badge-first.jpg'], 'seven badges are defined, each with artwork');
// badge cards: the board lists every card with lock state and share URLs; the share page and the
// stamped image exist only for badges the hunter holds
const bc = await call('/api/my/board'); const fc = bc.body.badgeCards.find(x => x.id === 'first'), tc = bc.body.badgeCards.find(x => x.id === 'tracker');
eq([bc.body.badgeCards.length, fc.earned, fc.page, tc.earned, tc.page], [7, true, B + '/b/hunter42/first', false, null], 'board: badge cards with lock state and share page');
const sp = await fetch(B + '/b/hunter42/first'); const spb = await sp.text(); eq([sp.status, /og:image" content="[^"]*\/badge-img\/hunter42\/first\.jpg"/.test(spb), /unlocked/.test(spb)], [200, true, true], 'share page serves with the stamped card as its OG image');
const sp404 = await fetch(B + '/b/hunter42/tracker'); eq(sp404.status, 404, 'no share page for a badge not held');
const bi = await fetch(B + '/badge-img/hunter42/first.jpg'); eq([bi.status, bi.headers.get('content-type')], [200, 'image/jpeg'], 'stamped badge image serves as JPEG');
const bi404 = await fetch(B + '/badge-img/nobody/first.jpg'); eq(bi404.status, 404, 'no card for an unknown hunter');
// weekly prizes: below the minimum nobody wins; with the minimum at 1 the leader gets 3 POL as a due prize drip // weekly prizes: below the minimum nobody wins; with the minimum at 1 the leader gets 3 POL as a due prize drip
// that does not count as a find or against the pool, and the week cannot be awarded twice // that does not count as a find or against the pool, and the week cannot be awarded twice
const social = require('../lib/social'); const thisMonday = social.weekOf(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' })); const social = require('../lib/social'); const thisMonday = social.weekOf(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }));