c316dab8e6
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>
122 lines
8.4 KiB
JavaScript
122 lines
8.4 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' },
|
||
{ 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 };
|