Referral rewards: pay a hunter for bringing someone who actually hunts

PolHunter retains well and recruits badly. On 2026-09-22 it had its best day,
105 finds from 37 hunters, and had sent three people to InstantAdPay in its
whole life, all three on one member's link. Earning and sharing were separate
actions and only one of them paid.

Bringing someone is now the best-paid thing on the board, and the card sits
first in the mission list rather than on a promo page nobody opens. A flat 3
POL the first time someone they brought finds a code, then 20% on top of that
person's finds for their first 30 days, paid from the pool and never taken out
of the newcomer's drip.

Who counts as "brought by me" is the sponsor they actually joined
InstantAdPay under, handed over in the sign-in payload, so the bounty and the
commission always land on the same person. The bounty only pays for a member
who is genuinely new, once per person, and referral drips have their own daily
ceiling so a good referral day can never starve the finds.

Also: findsToday defaulted its day argument to undefined and silently returned
zero for every caller that omitted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-23 04:34:22 -05:00
parent bed75d1b0f
commit 1c9e99f91e
7 changed files with 231 additions and 11 deletions
+174
View File
@@ -0,0 +1,174 @@
// Referral rewards (Marty, 2026-09-23).
//
// PolHunter retains well and recruits badly: on 2026-09-22 it had its best day ever, 105 finds
// from 37 hunters, and had sent three people to InstantAdPay in its whole life, all three on one
// member's link. Earning and sharing were separate actions and only one of them paid. This makes
// bringing someone the best-paid thing on the board.
//
// Two payments, both to the REFERRER, both out of a pool of their own so a good referral day can
// never starve the finds:
// bounty a flat drip the first time someone they brought completes a find, paid once per person
// match a slice of every find that person makes while they are still new
//
// Who counts as "brought by me" is not a cookie PolHunter guessed at. It is the sponsor the member
// actually joined InstantAdPay under, handed over in the sign-in payload as sponsorRef, so the
// bounty and the InstantAdPay commission always land on the same person.
'use strict';
const store = require('./store');
const rewards = require('./rewards');
const { ctDay } = require('./missions');
const DEFAULTS = {
refEnabled: 1,
refBountyPol: 3, // flat, not a draw: a referral is work someone did, not a lottery
refMatchPct: 20, // paid ON TOP of the newcomer's find, never taken out of it
refMatchDays: 30, // how long the match runs, from the newcomer's InstantAdPay join date
refNewDays: 30, // the bounty only pays for a genuinely new member, not an old one who
// happens to start hunting years after their sponsor signed them up
refCapPol: 20, // referral pool per Central day, separate from the find pool
refMatchMinPol: 0.005,// do not write a transaction for dust
};
function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
const round4 = n => Math.round(Number(n) * 10000) / 10000;
const norm = s => String(s || '').trim().toLowerCase();
// ---- the hunter directory -------------------------------------------------------------------
// Every sign-in upserts the member here, so the referral graph survives session expiry and we
// always have a wallet to pay the referrer at.
function seen(claims) {
const id = Number(claims.memberId);
if (!id) return null;
const all = store.update('hunters', {}, h => {
const cur = h[id] || { memberId: id, firstSeen: Date.now() };
if (claims.email) cur.email = norm(claims.email);
if (claims.username) cur.username = String(claims.username);
if (claims.wallet) cur.wallet = norm(claims.wallet);
if (claims.sponsorRef) cur.sponsorRef = norm(claims.sponsorRef);
if (claims.joinedAt) cur.joinedAt = Number(claims.joinedAt);
cur.lastSeen = Date.now();
h[id] = cur;
return h;
});
return all[id];
}
function hunter(memberId) { return store.read('hunters', {})[Number(memberId)] || null; }
function keysOf(h) { return [norm(h.username), String(h.memberId)].filter(Boolean); }
// the referrer has to be a hunter themselves: we pay into a wallet we have seen, and nobody
// earns from a board they have never opened
function referrerOf(memberId) {
const me = hunter(memberId);
if (!me || !me.sponsorRef) return null;
const all = store.read('hunters', {});
for (const k of Object.keys(all)) {
const x = all[k];
if (Number(x.memberId) === Number(memberId)) continue;
if (keysOf(x).includes(me.sponsorRef)) return x;
}
return null;
}
function broughtBy(memberId) {
const me = hunter(memberId);
if (!me) return [];
const keys = keysOf(me);
return Object.values(store.read('hunters', {}))
.filter(x => Number(x.memberId) !== Number(memberId) && x.sponsorRef && keys.includes(x.sponsorRef))
.sort((a, b) => (a.firstSeen || 0) - (b.firstSeen || 0));
}
// ---- the pool -------------------------------------------------------------------------------
const refRows = () => store.read('payouts', []).filter(p => p.ref && p.status !== 'failed');
function paidRefToday(day) { return refRows().filter(p => p.day === (day || ctDay())).reduce((n, p) => n + p.pol, 0); }
function refPool() {
const c = cfg();
const cap = Number(c.refCapPol), today = round4(paidRefToday());
return { capPol: cap, todayPol: today, left: round4(Math.max(0, cap - today)), resetsAt: rewards.nextResetAt() };
}
function write(referrer, pol, ref) {
const rec = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
memberId: Number(referrer.memberId), email: referrer.email || null, wallet: referrer.wallet, username: referrer.username || null,
missionId: 'ref:' + ref.kind, site: ref.kind === 'bounty' ? 'Referral bounty' : 'Referral match',
pol: round4(pol), day: ctDay(), at: Date.now(),
status: 'due', tx: null, paidAt: null, error: null, ref,
};
store.update('payouts', [], all => { all.push(rec); return all; });
return rec;
}
const bountyPaidFor = id => refRows().some(p => p.ref.kind === 'bounty' && Number(p.ref.forMemberId) === Number(id));
// ---- the hook: called right after a find is recorded ------------------------------------------
// Returns the drips it created, so the caller can post the bounty to Telegram.
function onFind(member, find) {
const c = cfg();
if (String(c.refEnabled) !== '1') return [];
const id = Number(member.memberId);
const me = hunter(id) || {};
const referrer = referrerOf(id);
if (!referrer || !referrer.wallet || Number(referrer.memberId) === id) return [];
const out = [];
let room = Number(c.refCapPol) - paidRefToday();
const ageDays = me.joinedAt ? (Date.now() - Number(me.joinedAt)) / 86400000 : 0;
const who = { forMemberId: id, forName: me.username || ('#' + id) };
// the bounty, once, on their first find, and only while they are genuinely a new member
const finds = store.read('payouts', []).filter(p => Number(p.memberId) === id && !p.ref && !p.prize && p.status !== 'failed');
if (finds.length <= 1 && ageDays <= Number(c.refNewDays) && !bountyPaidFor(id)) {
const pol = Number(c.refBountyPol);
if (pol <= room) { out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol; }
}
// the match, on every find inside the window, paid on top and never deducted from the newcomer
if (ageDays <= Number(c.refMatchDays)) {
const pol = round4(Number(find.pol) * Number(c.refMatchPct) / 100);
if (pol >= Number(c.refMatchMinPol) && pol <= room) out.push(write(referrer, pol, Object.assign({ kind: 'match', findId: find.id, findPol: find.pol }, who)));
}
return out;
}
// ---- what the board shows ---------------------------------------------------------------------
function state(member) {
const c = cfg();
const id = Number(member.memberId);
const me = hunter(id) || { memberId: id };
const rows = refRows().filter(p => Number(p.memberId) === id);
const today = ctDay();
const mine = broughtBy(id).map(x => {
const theirFinds = store.read('payouts', []).filter(p => Number(p.memberId) === Number(x.memberId) && !p.ref && !p.prize && p.status !== 'failed');
const paid = bountyPaidFor(x.memberId);
return {
name: x.username || ('#' + x.memberId),
joinedAt: x.firstSeen || null,
finds: theirFinds.length,
state: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet',
stillNew: x.joinedAt ? (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays) : false,
};
});
return {
on: String(c.refEnabled) === '1',
bountyPol: Number(c.refBountyPol),
matchPct: Number(c.refMatchPct),
matchDays: Number(c.refMatchDays),
brought: mine.length,
broughtToday: mine.filter(x => x.joinedAt && ctDay(x.joinedAt) === today).length,
earnedPol: round4(rows.reduce((n, p) => n + p.pol, 0)),
earnedTodayPol: round4(rows.filter(p => p.day === today).reduce((n, p) => n + p.pol, 0)),
people: mine.slice(0, 25),
pool: refPool(),
};
}
function totals() {
const rows = refRows();
const paid = rows.filter(p => p.status === 'paid');
return {
bounties: rows.filter(p => p.ref.kind === 'bounty').length,
matches: rows.filter(p => p.ref.kind === 'match').length,
pol: round4(rows.reduce((n, p) => n + p.pol, 0)),
paidPol: round4(paid.reduce((n, p) => n + p.pol, 0)),
referrers: new Set(rows.map(p => p.memberId)).size,
};
}
module.exports = { seen, hunter, referrerOf, broughtBy, onFind, state, totals, refPool, cfg, paidRefToday };
+6 -4
View File
@@ -32,10 +32,12 @@ function draw(min, max, skew) {
// 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; }
// day defaults to today: called without it the comparison was against undefined and silently
// returned 0 for everyone, which would read as "no finds yet" to any new caller
function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).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) {
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 && !p.ref).reduce((n, p) => n + p.pol, 0);
}
// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and
@@ -43,12 +45,12 @@ function paidToday(day) {
// two accounts sharing a wallet get one drip per mission per day between them.
function completed(memberId, missionId, wallet) {
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w)));
return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w)));
}
// the mission ids this member (or wallet) has done today
function doneToday(memberId, wallet) {
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId));
return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId));
}
// record a completion; decide paid-today vs queued
+1 -1
View File
@@ -24,7 +24,7 @@ const PRIZE_DEFAULTS = { weeklyPrizes: [3, 2, 1], weeklyMinFinds: 10 };
function prizeRules() { const s = rewards.settings(); return { pol: Array.isArray(s.weeklyPrizes) && s.weeklyPrizes.length ? s.weeklyPrizes.map(Number) : PRIZE_DEFAULTS.weeklyPrizes, minFinds: Number(s.weeklyMinFinds) > 0 ? Number(s.weeklyMinFinds) : PRIZE_DEFAULTS.weeklyMinFinds }; }
function excluded() { const s = rewards.settings(); const list = Array.isArray(s.leaderboardExclude) ? s.leaderboardExclude : [1]; return new Set(list.map(Number)); }
function finds() { return store.read('payouts', []).filter(p => p.status !== 'failed' && !p.prize); } // prize drips are not finds
function finds() { return store.read('payouts', []).filter(p => p.status !== 'failed' && !p.prize && !p.ref); } // prize drips are not finds
// Central day arithmetic on 'YYYY-MM-DD' keys
function dayMinus(dayKey, n) { const [y, m, d] = dayKey.split('-').map(Number); const t = Date.UTC(y, m - 1, d) - n * 86400000; return new Date(t).toISOString().slice(0, 10); }
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Your board · PolHunter</title>
<meta name="robots" content="noindex">
<link rel="stylesheet" href="/style.css?v=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body>
<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>
</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=12"></script>
<script src="/app.js?v=13"></script>
</body>
</html>
+27
View File
@@ -46,6 +46,26 @@
: '<div style="margin-top:14px"><button class="btn" data-start="' + esc(m.id) + '">Start mission</button></div>')
+ '</div>';
}
// The best-paid thing on the board, and the reason it sits first: sharing used to live on a page
// nobody opened, so nobody shared (Marty, 2026-09-23).
function referralCard() {
const rf = board.referral; if (!rf || !rf.on) return '';
const link = (board.share && board.share.link) || '';
const today = rf.broughtToday, total = rf.brought;
const people = (rf.people || []).slice(0, 5).map(p =>
'<div class="ref-row"><span>' + esc(p.name) + '</span><span class="ref-state">' + esc(p.state) + '</span></div>').join('');
return '<div class="card ref-card">'
+ '<span class="tag gold">Best paid</span>'
+ '<h3>🤝 Bring a hunter</h3>'
+ '<p>Send your link. When someone new opens PolHunter and finds their first code, you get <b>' + rf.bountyPol + ' POL</b>. '
+ 'After that you earn <b>' + rf.matchPct + '%</b> on top of everything they find for their first ' + rf.matchDays + ' days. '
+ 'It comes out of our pool, never theirs.</p>'
+ '<div class="codebox"><input id="refLink" readonly value="' + esc(link) + '"><button class="btn pol" id="refCopy">Copy</button></div>'
+ '<p style="margin-top:10px"><span class="range">' + today + ' brought today</span> '
+ '<span style="color:var(--dim);font-size:12px">· ' + total + ' all time · ' + rf.earnedPol + ' POL earned</span></p>'
+ (people ? '<div class="ref-list">' + people + '</div>' : '<p style="color:var(--dim);font-size:12px;margin-top:10px">Nobody yet. One message is all it takes.</p>')
+ '</div>';
}
const IAP = 'https://instantadpay.com';
function until(ms) { const s = Math.max(0, Math.round((ms - Date.now()) / 60000)); const h = Math.floor(s / 60), m = s % 60; return h ? h + 'h ' + m + 'm' : m + 'm'; }
// no wallet on the InstantAdPay account yet: the three steps, in place of the missions
@@ -83,7 +103,14 @@
const pool = board.pool || {};
if (!board.me.wallet) $('missions').innerHTML = onboarding();
else $('missions').innerHTML = (pool.spent ? '<div class="notice" style="grid-column:1/-1"><b>Today\u2019s POL pool is spent.</b> No more claims today. Claims reopen at midnight Central' + (pool.resetsAt ? ', in ' + until(pool.resetsAt) : '') + '.</div>' : '')
+ referralCard()
+ (board.missions.length ? board.missions.map(card).join('') : '<div class="card"><p>No missions are open right now. Check back soon.</p></div>');
const rc = $('refCopy');
if (rc) rc.addEventListener('click', async () => {
const v = $('refLink').value;
try { await navigator.clipboard.writeText(v); } catch (e) { $('refLink').select(); document.execCommand('copy'); }
rc.textContent = 'Copied'; setTimeout(() => { rc.textContent = 'Copy'; }, 1600);
});
// standing: badges and rank
const rk = board.rank || {}; const line = [];
if (rk.week) line.push('This week <b>#' + rk.week.rank + '</b> of ' + rk.week.of); if (rk.all) line.push('All time <b>#' + rk.all.rank + '</b> of ' + rk.all.of);
+7
View File
@@ -111,3 +111,10 @@ textarea{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(--
@media (max-width:820px){.hero{min-height:0;margin:0 -20px 6px;border-top:0;display:block}.hero-art{position:relative;inset:auto;display:block;height:270px}.hero-art img{object-position:60% 50%}.hero::before{inset:auto 0 auto 0;top:150px;height:121px;background:linear-gradient(180deg,transparent,var(--bg))}.hero-copy{padding:6px 20px 32px}.hero p{font-size:16.5px}.play{right:auto;left:14px;bottom:auto;top:214px;padding:9px 16px 9px 10px;font-size:13px;white-space:nowrap}.play .tri{width:28px;height:28px}.play .tri::after{left:10px;top:7px}.play .lbl-long{display:none}.nav a:nth-child(2),.nav a:nth-child(3){display:none}.adslot{overflow-x:auto;justify-content:flex-start}.row{grid-template-columns:1fr auto;row-gap:6px}.row .when{grid-column:1/-1;font-size:13px;color:var(--pol-hi)}.adslot{margin:18px -4px}}
/* every drip row carries its blockchain verify link; on phones it sits on its own line (Marty, 2026-09-19) */
.row a.when{color:var(--pol-hi)} .row a.when:hover{color:var(--gold-hi)}
/* Bring a hunter: the referral card sits first in the mission grid (Marty, 2026-09-23) */
.ref-card{border-color:rgba(243,190,67,.34);background:linear-gradient(170deg,rgba(243,190,67,.07),rgba(0,0,0,0) 60%)}
.ref-card .codebox input{font:500 13px var(--mono);letter-spacing:0;text-transform:none}
.ref-list{margin-top:12px;border-top:1px solid var(--edge);padding-top:8px}
.ref-row{display:flex;justify-content:space-between;gap:10px;font-size:13px;padding:4px 0}
.ref-row .ref-state{color:var(--dim);font-size:12px;text-align:right}
+14 -4
View File
@@ -20,6 +20,7 @@ const missions = require('./lib/missions');
const rewards = require('./lib/rewards');
const social = require('./lib/social');
const badge = require('./lib/badge');
const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
const faucet = require('./lib/faucet');
const PORT = Number(process.env.PORT || 3000);
@@ -71,6 +72,8 @@ async function notifyPaid(p) {
async function notifyOther(kind, p) {
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error);
// a bounty is the one payout that advertises the mission itself, so it goes to the room
if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} <b>PolHunter</b> · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · <b>' + fmt(d.pol) + ' POL</b> bounty on the way\n<a href="' + SITE + '">Bring yours</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>'); }
return false;
}
@@ -198,6 +201,7 @@ const server = http.createServer(async (req, res) => {
const cur = sso.fromRequest(req);
let sid = cur && cur.memberId === Number(v.claims.memberId) ? cur.sid : null;
if (sid) sso.refresh(sid, v.claims); else sid = sso.startSession(v.claims);
referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in
res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end();
}
if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); }
@@ -209,16 +213,18 @@ const server = http.createServer(async (req, res) => {
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 === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); }
if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); }
// ---- hunter (session required)
if (p.startsWith('/api/my/')) {
const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' });
if (p === '/api/my/board') {
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet
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') },
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' } });
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' },
referral: referrals.state(me) });
}
if (p === '/api/my/start' && req.method === 'POST') {
const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' });
@@ -244,6 +250,10 @@ const server = http.createServer(async (req, res) => {
{ 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 g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
let refDrips = [];
try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); }
for (const d of refDrips) if (d.ref.kind === 'bounty') notify('referral', { drip: d }).catch(() => {});
// 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(() => {});
@@ -255,7 +265,7 @@ const server = http.createServer(async (req, res) => {
// ---- admin (key)
if (p.startsWith('/api/admin/')) {
if (!admin(req)) return json(res, 401, { error: 'Admin key required.' });
if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } });
if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), referrals: referrals.totals(), referralPool: referrals.refPool(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } });
if (p === '/api/admin/mission' && req.method === 'POST') {
const b = await readBody(req);
const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' });
@@ -266,7 +276,7 @@ const server = http.createServer(async (req, res) => {
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', '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) }); }
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', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) 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
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 }); }