Files
polhunter/lib/rewards.js
T
martbost 0b4f8c627f Advertiser-funded missions: the accounting that keeps their money separate
First increment of paid missions. No advertiser can buy one yet; this is the
layer that has to be right before anyone can, because the faucet is a single
wallet holding house float and every advertiser's unspent reserve at once.

THE SOLVENCY RULE, in paid.canSell: faucet balance must cover the house float
plus every outstanding reserve before a sale is accepted. Break it and you have
sold delivery you cannot pay for, and a hunter finds a code only to watch the
drip fail. Checked before the sale, never after.

Three consequences of Marty's decision that paid missions sit ON TOP of the
daily cap rather than inside it:
  - a paid completion is excluded from paidToday, so it cannot eat house budget
  - each live paid mission the hunter has not done raises their allowance by one
  - a paid mission pays exactly what the advertiser set, with no random draw, so
    the reserve taken at purchase is exact and can never come up short

Advertisers set the hunter payout themselves, floored at 0.30, and may pay more
to be completed sooner. Company margin rides on top as a percentage.

Existing missions are untouched: nothing carries the paid flag, so house
behaviour is byte for byte what it was. 18 new tests, 79 across the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 09:40:17 -05:00

126 lines
8.3 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, doneIds) { const s = settings(); const base = Math.max(1, Number(s.missionsPerDay) || 3);
// every live paid mission this hunter has not done yet adds one, because the company is not
// funding that completion
let extra = 0; try { extra = require('./paid').extraAllowance(doneIds); } catch (e) {}
const limit = base + extra; const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
// What the COMPANY has spent today. Advertiser-funded completions are excluded on purpose: the
// hunter is paid out of what the advertiser already put in the faucet, so counting it here would
// let a paid mission use up the house budget (Marty, 2026-09-24).
function paidToday(day) {
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && !p.paid).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();
// An advertiser-funded mission pays exactly what the advertiser set, every time. No draw, so
// the reserve taken at purchase is exact and can never come up short.
const isPaid = !!mission.paid;
const pol = isPaid ? Math.round(Number(mission.hunterPol || 0) * 10000) / 10000
: 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.' };
// the house cap governs house spend only; a paid completion is already funded
const overCap = !isPaid && (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(),
paid: isPaid || undefined, // marks advertiser-funded spend so the house accounting skips it
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 };