From 7b2f71f2f728f296133ff7857ec298ad46af85a5 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 19 Sep 2026 19:53:03 -0500 Subject: [PATCH] Weekly prizes: top three of the Monday-to-Sunday Central week with 10+ finds get 3/2/1 POL as due prize drips (never finds, never against the pool), awarded once on the first tick after Sunday; Top Hunter badge; /api/prizes + winners on /leaders; admin award-by-hand Co-Authored-By: Claude Fable 5.1 --- lib/rewards.js | 3 ++- lib/social.js | 46 ++++++++++++++++++++++++++++++++++++++++++--- public/leaders.html | 13 ++++++++++++- server.js | 8 +++++++- test/run.js | 17 ++++++++++++++++- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/lib/rewards.js b/lib/rewards.js index 43c6882..24b19f8 100644 --- a/lib/rewards.js +++ b/lib/rewards.js @@ -25,8 +25,9 @@ function draw(min, max) { 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 function paidToday(day) { - return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed').reduce((n, p) => n + p.pol, 0); + 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 already? diff --git a/lib/social.js b/lib/social.js index dd2a8df..d5ecba1 100644 --- a/lib/social.js +++ b/lib/social.js @@ -18,17 +18,22 @@ const BADGES = [ { id: 'sweep', name: 'Full Sweep', icon: '\u{1F5FA}️', why: 'a find on every site' }, { id: 'lucky', name: 'Lucky Find', icon: '\u{1F48E}', why: 'one drip of 0.4 POL or more' }, { id: 'streak', name: 'On a Streak', icon: '\u{1F525}', why: 'finds on three days in a row' }, + { id: 'top', name: 'Top Hunter', icon: '\u{1F3C6}', why: 'won a weekly prize as #1' }, ]; +const PRIZE_DEFAULTS = { weeklyPrizes: [3, 2, 1], weeklyMinFinds: 10 }; +function prizeRules() { const s = rewards.settings(); return { pol: Array.isArray(s.weeklyPrizes) && s.weeklyPrizes.length ? s.weeklyPrizes.map(Number) : PRIZE_DEFAULTS.weeklyPrizes, minFinds: Number(s.weeklyMinFinds) > 0 ? Number(s.weeklyMinFinds) : PRIZE_DEFAULTS.weeklyMinFinds }; } function excluded() { const s = rewards.settings(); const list = Array.isArray(s.leaderboardExclude) ? s.leaderboardExclude : [1]; return new Set(list.map(Number)); } -function finds() { return store.read('payouts', []).filter(p => p.status !== 'failed'); } +function finds() { return store.read('payouts', []).filter(p => p.status !== 'failed' && !p.prize); } // prize drips are not finds // Central day arithmetic on 'YYYY-MM-DD' keys function dayMinus(dayKey, n) { const [y, m, d] = dayKey.split('-').map(Number); const t = Date.UTC(y, m - 1, d) - n * 86400000; return new Date(t).toISOString().slice(0, 10); } +// the Monday that starts a day's week (weeks run Monday to Sunday, Central) +function weekOf(dayKey) { const [y, m, d] = dayKey.split('-').map(Number); const dow = (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7; return dayMinus(dayKey, dow); } function inPeriod(p, period, today) { if (period === 'all') return true; if (period === 'month') return p.day.slice(0, 7) === today.slice(0, 7); - return p.day >= dayMinus(today, 6); // week: the last seven Central days including today + return weekOf(p.day) === weekOf(today); // week: this Monday-to-Sunday week } function badgesFor(memberId, list) { @@ -43,6 +48,7 @@ function badgesFor(memberId, list) { if (mine.some(p => p.pol >= 0.4)) out.add('lucky'); const days = new Set(mine.map(p => p.day)); for (const d of days) { if (days.has(dayMinus(d, 1)) && days.has(dayMinus(d, 2))) { out.add('streak'); break; } } + if (store.read('payouts', []).some(p => p.memberId === Number(memberId) && p.prize && p.prize.rank === 1 && p.status !== 'failed')) out.add('top'); return BADGES.filter(b => out.has(b.id)); } @@ -58,6 +64,40 @@ function leaderboard(period, limit) { return rows.slice(0, limit || 25).map((r, i) => Object.assign(r, { rank: i + 1, pol: Math.round(r.pol * 10000) / 10000, badges: badgesFor(r.memberId, all).map(b => b.icon) })); } +// standings for one fixed week (Monday key), qualified by the minimum finds +function weekRows(monday) { + const ex = excluded(); const all = finds(); const end = dayMinus(monday, -6); const by = new Map(); + for (const p of all) { + if (ex.has(p.memberId) || p.day < monday || p.day > end) continue; + const r = by.get(p.memberId) || { memberId: p.memberId, who: p.username ? '@' + p.username : '#' + p.memberId, username: p.username || null, email: p.email, wallet: p.wallet, finds: 0, pol: 0, last: 0 }; + r.finds++; if (p.status === 'paid') r.pol += p.pol; r.last = Math.max(r.last, p.at); if (p.wallet) r.wallet = p.wallet; by.set(p.memberId, r); + } + return [...by.values()].sort((a, b) => b.finds - a.finds || b.pol - a.pol || a.last - b.last); +} +// award one week, once: prize drips go on the ledger as due, the faucet pays them like any drip +function awardWeek(monday) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(monday || '') || weekOf(monday) !== monday) return { error: 'not a Monday key' }; + const done = store.read('prizes', []); + if (done.some(x => x.week === monday)) return { skipped: 'already awarded', week: monday }; + const rules = prizeRules(); + const winners = weekRows(monday).filter(r => r.finds >= rules.minFinds).slice(0, rules.pol.length).map((r, i) => Object.assign({ rank: i + 1, prizePol: rules.pol[i] }, r)); + const day = ctDay(), at = Date.now(); + const recs = winners.map(w => ({ id: 'prize' + monday.replace(/-/g, '') + '-' + w.rank, memberId: w.memberId, email: w.email, wallet: w.wallet, username: w.username, missionId: 'prize:' + monday, site: 'Weekly prize #' + w.rank, pol: w.prizePol, day, at, status: 'due', tx: null, paidAt: null, error: null, prize: { week: monday, rank: w.rank } })); + if (recs.length) store.update('payouts', [], all => { all.push(...recs); return all; }); + const entry = { week: monday, at, rules, winners: winners.map(w => ({ rank: w.rank, memberId: w.memberId, who: w.who, finds: w.finds, pol: w.pol, prizePol: w.prizePol })) }; + store.update('prizes', [], all => { all.push(entry); return all; }); + return entry; +} +// on every tick: the most recent completed week gets awarded on the first tick after it ends +function awardDue(notify) { + const last = dayMinus(weekOf(ctDay()), 7); + if (store.read('prizes', []).some(x => x.week === last)) return null; + const r = awardWeek(last); + if (r && r.winners && r.winners.length && notify) notify('prize', r).catch(() => {}); + return r; +} +function prizes() { const all = store.read('prizes', []); return { rules: prizeRules(), week: weekOf(ctDay()), last: all.length ? all[all.length - 1] : null, history: all.slice(-8).reverse() }; } + function rankOf(memberId, period) { const rows = leaderboard(period, 100000); const i = rows.findIndex(r => r.memberId === Number(memberId)); return i < 0 ? null : { rank: i + 1, of: rows.length, finds: rows[i].finds, pol: rows[i].pol }; @@ -67,4 +107,4 @@ function rankOf(memberId, period) { // their instantadpay.com/join/ link for everyone who signs up from it function shareLink(site, me) { const ref = me.username || me.memberId; return site + '/?r=' + encodeURIComponent(String(ref)); } -module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink }; +module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink, weekOf, weekRows, awardWeek, awardDue, prizes, prizeRules }; diff --git a/public/leaders.html b/public/leaders.html index 53ac445..96e4e97 100644 --- a/public/leaders.html +++ b/public/leaders.html @@ -21,10 +21,17 @@

Leaderboard

Most finds wins. Ties go to the most POL, then to whoever got there first.
+
Loading…
-

Weeks are the last seven days, Central time. The company account is not listed.

+

Weeks run Monday to Sunday, Central time. The company account is not listed.

+
+ +
@@ -51,6 +58,10 @@ else if (me) { document.getElementById('mine').style.display = 'block'; document.getElementById('mine').innerHTML = 'You are not on this board yet. One find puts you on it. Open your board.'; } } document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.p))); + try { const pz = await (await fetch('/api/prizes')).json(); const pol = pz.rules.pol; + document.getElementById('prizes').innerHTML = 'Weekly prizes: ' + pol.map((v, i) => medal(i + 1) + ' ' + v + ' POL').join(' \u00b7 ') + ' for the top ' + pol.length + ' with ' + pz.rules.minFinds + '+ finds. Paid to your wallet Monday after midnight Central. Weekly #1 also earns the Top Hunter badge.'; + if (pz.last && pz.last.winners.length) { document.getElementById('winners').style.display = 'block'; document.getElementById('winsub').textContent = 'Week of ' + pz.last.week + '.'; document.getElementById('winrows').innerHTML = pz.last.winners.map(w => '
' + medal(w.rank) + ' ' + esc(w.who) + '' + w.finds + ' finds+' + w.prizePol + ' POL prize
').join(''); } + } catch (e) {} const b = await (await fetch('/api/badges')).json(); document.getElementById('badges').innerHTML = b.badges.map(x => '
' + x.icon + '

' + esc(x.name) + '

' + esc(x.why) + '

' + (me && me.badges && me.badges.some(y => y.id === x.id) ? '

Earned

' : '') + '
').join(''); show('week'); diff --git a/server.js b/server.js index a5aa4fa..ec1496a 100644 --- a/server.js +++ b/server.js @@ -46,6 +46,7 @@ async function notify(kind, p) { if (kind === 'paid') return telegram('\u{1F3AF} PolHunter · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got ' + fmt(p.pol) + ' POL · verify\nHunt yours'); if (kind === 'low') return telegram('⚠️ PolHunter faucet is low: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.'); if (kind === 'failed') return telegram('❌ PolHunter · drip to #' + p.memberId + ' failed: ' + p.error); + if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} PolHunter weekly prizes for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 ' + fmt(w.prizePol) + ' POL').join('\n') + '\nLeaderboard'); } } // Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.'; @@ -145,6 +146,7 @@ const server = http.createServer(async (req, res) => { if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); } if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); } if (p === '/api/badges') return json(res, 200, { badges: social.BADGES }); + if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' }); if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html')); if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html')); if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); } @@ -199,7 +201,9 @@ 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']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); 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']) 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 }); } if (p === '/api/admin/faucet/tick' && req.method === 'POST') { const r = await faucet.tick(notify); return json(res, 200, Object.assign(r, { balance: await faucet.balance() })); } if (p === '/api/admin/embed-test') { // mint a token for any mission so the embed can be tried without a hunter @@ -221,5 +225,7 @@ const server = http.createServer(async (req, res) => { // the faucet pays every two minutes; nothing outward leaves unless OUTBOUND=on (telegram checks) if (faucetOn) setInterval(() => faucet.tick(notify).catch(e => console.error('faucet', e.message)), 2 * 60000); +// weekly prizes: the week that just ended is awarded on the first tick after Sunday, Central +setInterval(() => { try { social.awardDue(notify); } catch (e) { console.error('prizes', e.message); } }, 2 * 60000); server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'} — sso: ${sso.enabled() ? 'on' : 'off'} — faucet: ${faucetOn ? faucet.address() + ' chain ' + (process.env.HUNT_CHAIN_ID || '?') : 'off'}`)); diff --git a/test/run.js b/test/run.js index 34d9538..9c94c53 100644 --- a/test/run.js +++ b/test/run.js @@ -80,7 +80,22 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); // 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 const lb = await call('/api/leaders?period=all'); eq([lb.status, lb.body.rows.length >= 1, lb.body.rows[0].memberId, lb.body.rows[0].finds >= 1, lb.body.rows[0].badges.length >= 1], [200, true, 42, true, true], 'leaderboard: hunter 42 leads all time with a badge'); - const bd = await call('/api/badges'); eq(bd.body.badges.length, 6, 'six badges are defined'); + const bd = await call('/api/badges'); eq(bd.body.badges.length, 7, 'seven badges are defined'); + // weekly prizes: below the minimum nobody wins; with the minimum at 1 the leader gets 3 POL as a due prize drip + // that does not count as a find or against the pool, and the week cannot be awarded twice + const social = require('../lib/social'); const thisMonday = social.weekOf(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' })); + const pz0 = await (await fetch(B + '/api/prizes')).json(); eq([pz0.rules.pol, pz0.rules.minFinds, pz0.week], [[3, 2, 1], 10, thisMonday], 'prize rules default to 3/2/1 POL, 10 finds, this Monday'); + const aw0 = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: thisMonday }) }); eq([aw0.status, aw0.body.winners.length], [200, 0], 'below the minimum finds nobody is awarded'); + await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ weeklyMinFinds: 1 }) }); + // the week above is now recorded as awarded with no winners; award a fresh key by hand is refused for non-Mondays + const bad2 = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: '2026-01-01' }) }); eq(bad2.status, 400, 'a non-Monday week key is refused'); + const twiceP = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: thisMonday }) }); eq(twiceP.body.skipped, 'already awarded', 'a week is never awarded twice'); + { const fs2 = require('fs'); const pf = path.join(DIR, 'prizes.json'); fs2.writeFileSync(pf, '[]'); } + const aw1 = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: thisMonday }) }); + eq([aw1.status, aw1.body.winners.length, aw1.body.winners[0].memberId, aw1.body.winners[0].prizePol], [200, 1, 42, 3], 'with the minimum at 1, hunter 42 wins 3 POL'); + const b4 = await call('/api/my/board'); const pr = b4.body.drips.find(d => d.site === 'Weekly prize #1'); + eq([!!pr, pr && pr.status, pr && pr.pol, b4.body.badges.some(x => x.id === 'top'), b4.body.rank.all.finds], [true, 'due', 3, true, 1], 'the prize is a due drip on the board, Top Hunter badge on, finds unchanged'); + const lb2 = await call('/api/leaders?period=all'); eq(lb2.body.rows[0].finds, 1, 'the prize drip is not a find on the leaderboard'); const b3 = await call('/api/my/board'); eq([b3.body.badges.some(b => b.id === 'first'), b3.body.rank.all.rank, b3.body.share.link], [true, 1, B + '/?r=hunter42'], 'board: First Find badge, rank #1, share link carries the username'); const rv = await fetch(B + '/?r=hunter42', { redirect: 'manual' }); eq([rv.status, /ph\.ref=hunter42/.test(rv.headers.get('set-cookie') || ''), rv.headers.get('location')], [302, true, '/'], 'a share-link visit sets the referral cookie and lands on the landing'); const cf = await (await fetch(B + '/api/config', { headers: { Cookie: 'ph.ref=hunter42' } })).json(); eq([cf.ref, cf.joinUrl], ['hunter42', 'https://instantadpay.com/join/hunter42?from=polhunter'], 'with the cookie, sign-ups go to that member\u2019s IAP join link');