diff --git a/lib/referrals.js b/lib/referrals.js new file mode 100644 index 0000000..21a166e --- /dev/null +++ b/lib/referrals.js @@ -0,0 +1,174 @@ +// 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), + 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 }; diff --git a/lib/rewards.js b/lib/rewards.js index d19f7e4..7d402dd 100644 --- a/lib/rewards.js +++ b/lib/rewards.js @@ -32,10 +32,12 @@ function draw(min, max, skew) { // prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool // a hunter's finds today (Central), prizes excluded -function findsToday(memberId, day) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === day && p.status !== 'failed' && !p.prize).length; } +// day defaults to today: called without it the comparison was against undefined and silently +// returned 0 for everyone, which would read as "no finds yet" to any new caller +function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; } function daily(memberId) { const s = settings(); const limit = Math.max(1, Number(s.missionsPerDay) || 3); const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; } function paidToday(day) { - return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize).reduce((n, p) => n + p.pol, 0); + return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref).reduce((n, p) => n + p.pol, 0); } // has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and @@ -43,12 +45,12 @@ function paidToday(day) { // two accounts sharing a wallet get one drip per mission per day between them. function completed(memberId, missionId, wallet) { const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); - return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))); + return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))); } // the mission ids this member (or wallet) has done today function doneToday(memberId, wallet) { const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); - return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId)); + return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId)); } // record a completion; decide paid-today vs queued diff --git a/lib/social.js b/lib/social.js index b9c7c95..460d761 100644 --- a/lib/social.js +++ b/lib/social.js @@ -24,7 +24,7 @@ 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 +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); } diff --git a/public/app.html b/public/app.html index 30b3a71..5e1b674 100644 --- a/public/app.html +++ b/public/app.html @@ -5,7 +5,7 @@