Files
polhunter/lib/paid.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

97 lines
4.6 KiB
JavaScript

// Advertiser-funded missions (Marty, 2026-09-24).
//
// A member buys a mission: they pay POL into the faucet wallet, and hunters who complete it are
// dripped out of what they paid. Two rules follow from that, and everything here exists to hold
// them:
//
// 1. A paid completion is NOT house spend. It does not count against dailyCapPol, and it does
// not use up one of the hunter's missionsPerDay. Each live paid mission raises that hunter's
// allowance by one, because the company is not funding it. ("Each additional mission paid by
// an advertiser should allow +1 above the cap.")
//
// 2. THE SOLVENCY RULE. The faucet is one wallet holding house float AND every advertiser's
// unspent reserve. If reserves ever exceed the balance we have sold delivery we cannot pay
// for, and a hunter completes a mission only to watch the drip fail. So:
//
// faucet balance >= house float + sum(outstanding reserves)
//
// checked before a sale is accepted, never after.
//
// Money in is split at purchase: the hunter payout is reserved, the rest is company margin the
// P&L can recognise. The advertiser sets the hunter payout themselves (base 0.30, they may pay
// more to get completed sooner), so the reserve is exact rather than estimated. No draw, no
// variance, no guessing.
'use strict';
const store = require('./store');
const DEFAULTS = {
paidEnabled: 1,
paidBaseHunterPol: 0.30, // the floor an advertiser may set as the hunter's payout
paidMarginPct: 100, // company margin on top of the hunter payout, as a percentage
paidMinBlock: 100, // completions per block
paidMaxConcurrent: 0, // 0 = no limit; supply is elastic now that paid missions add capacity
paidHouseFloatPol: 25, // POL kept back for house missions, never counted as sellable
};
function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
const r4 = n => Math.round(Number(n) * 10000) / 10000;
// ---- what a block costs -----------------------------------------------------------------
// hunterPol is what each completing hunter receives; the advertiser pays that plus the margin.
function quote(hunterPol, completions) {
const c = cfg();
const hp = r4(Math.max(Number(c.paidBaseHunterPol), Number(hunterPol) || 0));
const n = Math.max(Number(c.paidMinBlock), Math.round(Number(completions) || 0));
const reserve = r4(hp * n); // exact: no draw, so no buffer needed
const margin = r4(reserve * (Number(c.paidMarginPct) / 100));
return { hunterPol: hp, completions: n, reserve, margin, total: r4(reserve + margin) };
}
// ---- reserves ---------------------------------------------------------------------------
const paidMissions = () => store.read('missions', []).filter(m => m.paid);
// completions already granted against a mission (failed ones do not count, they never paid)
function usedOf(missionId) {
return store.read('payouts', []).filter(p => p.missionId === missionId && p.status !== 'failed' && !p.prize && !p.ref).length;
}
// what every live paid mission still owes its hunters
function outstanding() {
let pol = 0, left = 0;
for (const m of paidMissions()) {
const remaining = Math.max(0, Number(m.budget || 0) - usedOf(m.id));
left += remaining;
pol += remaining * Number(m.hunterPol || 0);
}
return { reservePol: r4(pol), completionsLeft: left, missions: paidMissions().length };
}
// THE guard. Given the faucet's real balance, can we take this sale?
function canSell(balancePol, addReservePol) {
const c = cfg();
const o = outstanding();
const float = Number(c.paidHouseFloatPol) || 0;
const committed = r4(o.reservePol + float + Number(addReservePol || 0));
const bal = r4(Number(balancePol) || 0);
return {
ok: bal >= committed,
balance: bal, committed, houseFloat: float,
alreadyReserved: o.reservePol, adding: r4(Number(addReservePol) || 0),
shortBy: bal >= committed ? 0 : r4(committed - bal),
};
}
// ---- the hunter's allowance ---------------------------------------------------------------
// Base allowance is the company-funded missionsPerDay. Every live paid mission the hunter has
// not yet done adds one, because that completion costs the company nothing.
function extraAllowance(doneIds) {
const done = doneIds instanceof Set ? doneIds : new Set(doneIds || []);
return paidMissions().filter(m => m.active && !done.has(m.id) && Math.max(0, Number(m.budget || 0) - usedOf(m.id)) > 0).length;
}
function isPaid(missionId) {
const m = store.read('missions', []).find(x => x.id === missionId);
return !!(m && m.paid);
}
module.exports = { cfg, quote, outstanding, canSell, extraAllowance, isPaid, usedOf, paidMissions };