From 7b061480cb714a8094ce214eeb3a679a0a6c5b9d Mon Sep 17 00:00:00 2001 From: martbost Date: Sun, 20 Sep 2026 05:04:24 -0500 Subject: [PATCH] Launch build: k=3 floor-weighted draw, three missions per hunter per day, HUNT_LIVE_AT lifts the curtain and opens outbound at the launch moment, badge photos to topic + general, toasts on the board Co-Authored-By: Claude Fable 5.1 --- lib/rewards.js | 16 ++++++++++++---- public/admin.html | 2 +- public/app.html | 4 ++-- public/app.js | 30 +++++++++++++++++++++++++++--- public/index.html | 2 +- public/leaders.html | 2 +- public/promo.html | 2 +- public/style.css | 5 +++++ server.js | 25 ++++++++++++++++--------- test/run.js | 11 +++++++++-- 10 files changed, 75 insertions(+), 24 deletions(-) diff --git a/lib/rewards.js b/lib/rewards.js index 24b19f8..e7f183a 100644 --- a/lib/rewards.js +++ b/lib/rewards.js @@ -15,17 +15,25 @@ const store = require('./store'); const { ctDay } = require('./missions'); -const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40 }; +// 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) { +function draw(min, max, skew) { const lo = Math.log(min), hi = Math.log(max); - const v = Math.exp(lo + Math.random() * (hi - lo)); + 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); } @@ -88,4 +96,4 @@ function totals() { 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, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt }; +module.exports = { settings, setSettings, draw, grant, completed, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday }; diff --git a/public/admin.html b/public/admin.html index 54eab4a..ecf51f2 100644 --- a/public/admin.html +++ b/public/admin.html @@ -5,7 +5,7 @@ Admin · PolHunter - +

Coming soon

This site is still being built.

`; function curtained(req, res, u) { - if (!CURTAIN) return false; + if (!CURTAIN || (LIVE_AT && live())) return false; // the launch moment lifts the curtain if (u.searchParams.get('k') === CURTAIN) { u.searchParams.delete('k'); res.writeHead(302, { 'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure', Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' }); res.end(); return true; } const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || ''); if (m && decodeURIComponent(m[1]) === CURTAIN) return false; @@ -117,7 +122,7 @@ const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief, const server = http.createServer(async (req, res) => { try { const u = new URL(req.url, 'http://x'); const p = u.pathname; - if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null }); + if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN && !(LIVE_AT && live()), live: live(), liveAt: LIVE_AT ? new Date(LIVE_AT).toISOString() : null, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null }); // the embed talks to us from the mission sites: it must work through the curtain, and it must // answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first) @@ -144,7 +149,7 @@ const server = http.createServer(async (req, res) => { const share = encodeURIComponent(title + ' ' + url); const html = '' + esc(title) + '' + '' - + '' + + '' + '
PolHunter
' + '
' + esc(b.name) + ' badge for ' + esc(m.who) + '

' + esc(m.who) + ' unlocked ' + esc(b.name) + '

' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.

' + 'Hunt yours' @@ -192,7 +197,7 @@ const server = http.createServer(async (req, res) => { if (p === '/api/my/board') { const done = new Set(rewards.mine(me.memberId).map(x => x.missionId)); const wdone = me.wallet ? new Set(store.read('payouts', []).filter(x => x.wallet && x.wallet.toLowerCase() === me.wallet && x.status !== 'failed').map(x => x.missionId)) : new Set(); - return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), + return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId), badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') }, share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) } }); } @@ -201,6 +206,7 @@ const server = http.createServer(async (req, res) => { if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' }); if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } + { const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); } if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' }); const t = missions.issue(me.memberId, m.id); // a mission URL may place the token itself with {token} (a Telegram Mini App takes it in @@ -216,6 +222,7 @@ const server = http.createServer(async (req, res) => { const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' }); if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } + { const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); } const before = new Set(social.badgesFor(me.memberId).map(b => b.id)); const g = rewards.grant(me, m); if (g.error) return json(res, 400, g); // achievements unlocked by this find: told to the board, posted to Telegram (gated) @@ -240,7 +247,7 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, missions: missions.list() }); } if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); } - if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } + if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } // award a week by hand (its Monday key); already-awarded weeks are skipped if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); } if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); } diff --git a/test/run.js b/test/run.js index cc14750..6fac982 100644 --- a/test/run.js +++ b/test/run.js @@ -74,8 +74,15 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); const s3 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq(s3.status, 200, 'with room in the pool the start is accepted again'); // the draw is weighted low - const rewards = require('../lib/rewards'); const draws = Array.from({ length: 4000 }, () => rewards.draw(0.05, 1)); - const median = draws.sort((x, y) => x - y)[2000]; eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.35], [true, true], 'the draw stays in range and its median sits low (' + median + ')'); + const rewards = require('../lib/rewards'); const draws = Array.from({ length: 6000 }, () => rewards.draw(0.05, 1, 3)); + const median = draws.sort((x, y) => x - y)[3000]; const mean = draws.reduce((a, b) => a + b, 0) / draws.length; + eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.12, mean < 0.16, draws.some(d => d > 0.5)], [true, true, true, true], 'the k=3 draw stays in range, median ~0.09, mean ~0.13, a big drip still happens (' + median.toFixed(3) + '/' + mean.toFixed(3) + ')'); + // three missions a day: with the limit set to 1, the second start of the day is refused and the board says so + await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ missionsPerDay: 1 }) }); + const bdl = await call('/api/my/board'); eq([bdl.body.daily.limit, bdl.body.daily.done, bdl.body.daily.left], [1, 1, 0], 'board: daily limit, done and left'); + const sdl = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq([sdl.status, sdl.body.dailyDone, /for today/.test(sdl.body.error)], [400, true, true], 'past the daily limit a start is refused with the midnight message'); + await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ missionsPerDay: 3 }) }); + const h2 = await call('/health'); eq([h2.body.live, h2.body.liveAt], [true, null], 'no HUNT_LIVE_AT: live now'); // social: the find is on the leaderboard with a badge, the board carries rank and a share link, // and a share link visit sets the referral cookie that turns sign-ups into that member's IAP join link