Files
polhunter/lib/social.js
T

111 lines
7.4 KiB
JavaScript
Raw 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' },
];
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
// 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
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 };