1c9e99f91e
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>
119 lines
8.0 KiB
JavaScript
119 lines
8.0 KiB
JavaScript
// Leaderboard, badges and share links. All derived from the payout ledger; nothing new is stored.
|
||
//
|
||
// A "find" is any non-failed payout (paid, sent, due or queued): the hunter did the work. POL counts
|
||
// paid drips only, so the board never shows money that has not left the faucet. Days and weeks are
|
||
// Central (ctDay), the same clock as the pool.
|
||
//
|
||
// settings.leaderboardExclude: member ids kept off the public boards (default: the company account #1).
|
||
'use strict';
|
||
const store = require('./store');
|
||
const { ctDay } = require('./missions');
|
||
const rewards = require('./rewards');
|
||
const missions = require('./missions');
|
||
|
||
const BADGES = [
|
||
{ id: 'first', name: 'First Find', icon: '\u{1F3AF}', why: 'your first code claimed' },
|
||
{ id: 'hunter', name: 'Hunter', icon: '\u{1F9ED}', why: 'five finds' },
|
||
{ id: 'tracker', name: 'Tracker', icon: '\u{1F526}', why: 'twenty finds' },
|
||
{ id: 'sweep', name: 'Full Sweep', icon: '\u{1F5FA}️', why: 'a find on every site' },
|
||
{ id: 'lucky', name: 'Lucky Find', icon: '\u{1F48E}', why: 'one drip of 0.4 POL or more' },
|
||
{ id: 'streak', name: 'On a Streak', icon: '\u{1F525}', why: 'finds on three days in a row' },
|
||
{ id: 'top', name: 'Top Hunter', icon: '\u{1F3C6}', why: 'won a weekly prize as #1' },
|
||
];
|
||
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 && !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); }
|
||
// the Monday that starts a day's week (weeks run Monday to Sunday, Central)
|
||
function weekOf(dayKey) { const [y, m, d] = dayKey.split('-').map(Number); const dow = (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7; return dayMinus(dayKey, dow); }
|
||
function inPeriod(p, period, today) {
|
||
if (period === 'all') return true;
|
||
if (period === 'month') return p.day.slice(0, 7) === today.slice(0, 7);
|
||
return weekOf(p.day) === weekOf(today); // week: this Monday-to-Sunday week
|
||
}
|
||
|
||
function badgesFor(memberId, list) {
|
||
const mine = (list || finds()).filter(p => p.memberId === Number(memberId));
|
||
if (!mine.length) return [];
|
||
const out = new Set();
|
||
out.add('first');
|
||
if (mine.length >= 5) out.add('hunter');
|
||
if (mine.length >= 20) out.add('tracker');
|
||
const sites = new Set(missions.list().filter(m => m.active !== false).map(m => m.site));
|
||
if (sites.size && [...sites].every(s => mine.some(p => p.site === s))) out.add('sweep');
|
||
if (mine.some(p => p.pol >= 0.4)) out.add('lucky');
|
||
const days = new Set(mine.map(p => p.day));
|
||
for (const d of days) { if (days.has(dayMinus(d, 1)) && days.has(dayMinus(d, 2))) { out.add('streak'); break; } }
|
||
if (store.read('payouts', []).some(p => p.memberId === Number(memberId) && p.prize && p.prize.rank === 1 && p.status !== 'failed')) out.add('top');
|
||
return BADGES.filter(b => out.has(b.id));
|
||
}
|
||
|
||
function leaderboard(period, limit) {
|
||
const today = ctDay(); const ex = excluded(); const all = finds();
|
||
const by = new Map();
|
||
for (const p of all) {
|
||
if (ex.has(p.memberId) || !inPeriod(p, period, today)) continue;
|
||
const r = by.get(p.memberId) || { memberId: p.memberId, who: p.username ? '@' + p.username : '#' + p.memberId, finds: 0, pol: 0, last: 0 };
|
||
r.finds++; if (p.status === 'paid') r.pol += p.pol; r.last = Math.max(r.last, p.at); by.set(p.memberId, r);
|
||
}
|
||
const rows = [...by.values()].sort((a, b) => b.finds - a.finds || b.pol - a.pol || a.last - b.last);
|
||
return rows.slice(0, limit || 25).map((r, i) => Object.assign(r, { rank: i + 1, pol: Math.round(r.pol * 10000) / 10000, badges: badgesFor(r.memberId, all).map(b => b.icon) }));
|
||
}
|
||
|
||
// standings for one fixed week (Monday key), qualified by the minimum finds
|
||
function weekRows(monday) {
|
||
const ex = excluded(); const all = finds(); const end = dayMinus(monday, -6); const by = new Map();
|
||
for (const p of all) {
|
||
if (ex.has(p.memberId) || p.day < monday || p.day > end) continue;
|
||
const r = by.get(p.memberId) || { memberId: p.memberId, who: p.username ? '@' + p.username : '#' + p.memberId, username: p.username || null, email: p.email, wallet: p.wallet, finds: 0, pol: 0, last: 0 };
|
||
r.finds++; if (p.status === 'paid') r.pol += p.pol; r.last = Math.max(r.last, p.at); if (p.wallet) r.wallet = p.wallet; by.set(p.memberId, r);
|
||
}
|
||
return [...by.values()].sort((a, b) => b.finds - a.finds || b.pol - a.pol || a.last - b.last);
|
||
}
|
||
// award one week, once: prize drips go on the ledger as due, the faucet pays them like any drip
|
||
function awardWeek(monday) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(monday || '') || weekOf(monday) !== monday) return { error: 'not a Monday key' };
|
||
const done = store.read('prizes', []);
|
||
if (done.some(x => x.week === monday)) return { skipped: 'already awarded', week: monday };
|
||
const rules = prizeRules();
|
||
const winners = weekRows(monday).filter(r => r.finds >= rules.minFinds).slice(0, rules.pol.length).map((r, i) => Object.assign({ rank: i + 1, prizePol: rules.pol[i] }, r));
|
||
const day = ctDay(), at = Date.now();
|
||
const recs = winners.map(w => ({ id: 'prize' + monday.replace(/-/g, '') + '-' + w.rank, memberId: w.memberId, email: w.email, wallet: w.wallet, username: w.username, missionId: 'prize:' + monday, site: 'Weekly prize #' + w.rank, pol: w.prizePol, day, at, status: 'due', tx: null, paidAt: null, error: null, prize: { week: monday, rank: w.rank } }));
|
||
if (recs.length) store.update('payouts', [], all => { all.push(...recs); return all; });
|
||
const entry = { week: monday, at, rules, winners: winners.map(w => ({ rank: w.rank, memberId: w.memberId, who: w.who, finds: w.finds, pol: w.pol, prizePol: w.prizePol })) };
|
||
store.update('prizes', [], all => { all.push(entry); return all; });
|
||
return entry;
|
||
}
|
||
// on every tick: the most recent completed week gets awarded on the first tick after it ends
|
||
function awardDue(notify) {
|
||
const last = dayMinus(weekOf(ctDay()), 7);
|
||
if (store.read('prizes', []).some(x => x.week === last)) return null;
|
||
const r = awardWeek(last);
|
||
if (r && r.winners && r.winners.length && notify) notify('prize', r).catch(() => {});
|
||
return r;
|
||
}
|
||
function prizes() { const all = store.read('prizes', []); return { rules: prizeRules(), week: weekOf(ctDay()), last: all.length ? all[all.length - 1] : null, history: all.slice(-8).reverse() }; }
|
||
|
||
function rankOf(memberId, period) {
|
||
const rows = leaderboard(period, 100000); const i = rows.findIndex(r => r.memberId === Number(memberId));
|
||
return i < 0 ? null : { rank: i + 1, of: rows.length, finds: rows[i].finds, pol: rows[i].pol };
|
||
}
|
||
|
||
// 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
|
||
// 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)); }
|
||
|
||
module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink, weekOf, weekRows, awardWeek, awardDue, prizes, prizeRules, nameOf, memberByName };
|