Files
martbost c316dab8e6 Recruiter badge: the card for bringing someone who actually hunts
Matches the existing set at 1080x1080, same violet and gold, same laurel
wreath and floating Polygon coins, with a blank ribbon for the hunter's name.
Inside the wreath, four anonymous figures wired together as a team tree, one
at the top lighting up the three below it.

Awarded on a bounty rather than a match, so it means a person they brought
turned up and found their first code, not merely that an existing hunter
kept hunting. It shows locked on the board until then, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:10:00 -05:00

122 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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' },
{ id: 'recruiter', name: 'Recruiter', icon: '\u{1F91D}', why: 'brought a hunter who found their first code' },
];
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');
// earned by a bounty, not a match: someone they brought turned up and found their first code
if (store.read('payouts', []).some(p => p.memberId === Number(memberId) && p.ref && p.ref.kind === 'bounty' && p.status !== 'failed')) out.add('recruiter');
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 };