Files
polhunter/lib/rewards.js
T
martbost 1c9e99f91e Referral rewards: pay a hunter for bringing someone who actually hunts
PolHunter retains well and recruits badly. On 2026-09-22 it had its best day,
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.

Bringing someone is now the best-paid thing on the board, and the card sits
first in the mission list rather than on a promo page nobody opens. A flat 3
POL the first time someone they brought finds a code, then 20% on top of that
person's finds for their first 30 days, paid from the pool and never taken out
of the newcomer's drip.

Who counts as "brought by me" is the sponsor they actually joined
InstantAdPay under, handed over in the sign-in payload, so the bounty and the
commission always land on the same person. The bounty only pays for a member
who is genuinely new, once per person, and referral drips have their own daily
ceiling so a good referral day can never starve the finds.

Also: findsToday defaulted its day argument to undefined and silently returned
zero for every caller that omitted it.

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

113 lines
7.2 KiB
JavaScript

// Rewards: the weighted draw, the daily cap, the queue, and the ledger.
//
// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol }
// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant,
// because POL's dollar price moves.
//
// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near
// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever
// the cap.
//
// A completion is recorded the moment the proof checks out. If today's paid total plus this
// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in
// order; the hunter did the work and is never told no. The faucet pays 'due' entries.
'use strict';
const store = require('./store');
const { ctDay } = require('./missions');
// drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor
// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per
// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool)
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 };
function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); }
function draw(min, max, skew) {
const lo = Math.log(min), hi = Math.log(max);
const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1);
const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor
const v = Math.exp(hi - u * (hi - lo));
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
}
// 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
// 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 && !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
// the pool (Marty, 2026-09-20: yesterday's finds showed as done today). Checked by member id, and also by wallet:
// 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.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.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
function grant(member, mission) {
const s = settings();
const pol = draw(Number(s.minPol), Number(s.maxPol));
const day = ctDay();
const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity;
if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' };
const overCap = paidToday(day) + pol > Number(s.dailyCapPol);
const rec = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null,
missionId: mission.id, site: mission.site, pol, day, at: Date.now(),
status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null,
};
store.update('payouts', [], all => { all.push(rec); return all; });
return { ok: true, rec, queued: overCap };
}
// what the faucet should pay now: due entries, then queued ones as long as today's cap allows
function payable() {
const s = settings(); const day = ctDay();
let room = Number(s.dailyCapPol) - paidToday(day);
const all = store.read('payouts', []);
const out = [];
for (const p of all.filter(p => p.status === 'due')) { out.push(p); }
for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; }
return out;
}
// paid drips whose find has not been posted yet (found at or after `since`), oldest paid first
function unposted(since, limit) {
return store.read('payouts', []).filter(p => p.status === 'paid' && p.tx && !p.posted && (p.at || 0) >= (since || 0))
.sort((a, b) => (a.paidAt || a.at) - (b.paidAt || b.at)).slice(0, Math.max(1, limit || 15));
}
function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); }
// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it
// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight)
function nextResetAt(now) {
const t0 = now || Date.now(); const today = ctDay(t0);
let lo = t0, hi = t0 + 26 * 3600000;
while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; }
return hi;
}
function pool() {
const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol);
return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() };
}
function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); }
function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); }
function totals() {
const all = store.read('payouts', []);
const paid = all.filter(p => p.status === 'paid');
return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size };
}
module.exports = { settings, setSettings, draw, grant, completed, doneToday, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday, unposted };