48611374d6
The backfill stamped firstSeen at seeding time, so every hunter already on the board read as brought in today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
8.4 KiB
JavaScript
177 lines
8.4 KiB
JavaScript
// 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),
|
|
// when they actually joined InstantAdPay, not when this directory first noticed them:
|
|
// the backfill stamped firstSeen at seeding time and made everyone look brought-in today
|
|
joinedAt: x.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 };
|