Files
polhunter/lib/rewards.js
T

111 lines
7.0 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
function findsToday(memberId, day) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === day && p.status !== 'failed' && !p.prize).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);
}
// 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.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));
}
// 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 };