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 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -25,8 +25,9 @@ function draw(min, max) {
|
|||||||
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
|
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) {
|
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?
|
// has this member completed this mission already?
|
||||||
|
|||||||
+43
-3
@@ -18,17 +18,22 @@ const BADGES = [
|
|||||||
{ id: 'sweep', name: 'Full Sweep', icon: '\u{1F5FA}️', why: 'a find on every site' },
|
{ 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: '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: '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 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
|
// 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); }
|
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) {
|
function inPeriod(p, period, today) {
|
||||||
if (period === 'all') return true;
|
if (period === 'all') return true;
|
||||||
if (period === 'month') return p.day.slice(0, 7) === today.slice(0, 7);
|
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) {
|
function badgesFor(memberId, list) {
|
||||||
@@ -43,6 +48,7 @@ function badgesFor(memberId, list) {
|
|||||||
if (mine.some(p => p.pol >= 0.4)) out.add('lucky');
|
if (mine.some(p => p.pol >= 0.4)) out.add('lucky');
|
||||||
const days = new Set(mine.map(p => p.day));
|
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; } }
|
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));
|
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) }));
|
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) {
|
function rankOf(memberId, period) {
|
||||||
const rows = leaderboard(period, 100000); const i = rows.findIndex(r => r.memberId === Number(memberId));
|
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 };
|
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/<ref> link for everyone who signs up from it
|
// their instantadpay.com/join/<ref> link for everyone who signs up from it
|
||||||
function shareLink(site, me) { const ref = me.username || me.memberId; return site + '/?r=' + encodeURIComponent(String(ref)); }
|
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 };
|
||||||
|
|||||||
+12
-1
@@ -21,10 +21,17 @@
|
|||||||
<section class="section" style="padding-top:14px">
|
<section class="section" style="padding-top:14px">
|
||||||
<h2>Leaderboard</h2>
|
<h2>Leaderboard</h2>
|
||||||
<div class="sub">Most finds wins. Ties go to the most POL, then to whoever got there first.</div>
|
<div class="sub">Most finds wins. Ties go to the most POL, then to whoever got there first.</div>
|
||||||
|
<div class="notice" id="prizes" style="margin:12px 0"></div>
|
||||||
<div class="tabs" id="tabs"><button class="tab on" data-p="week">This week</button><button class="tab" data-p="month">This month</button><button class="tab" data-p="all">All time</button></div>
|
<div class="tabs" id="tabs"><button class="tab on" data-p="week">This week</button><button class="tab" data-p="month">This month</button><button class="tab" data-p="all">All time</button></div>
|
||||||
<div class="notice" id="mine" style="display:none;margin:12px 0"></div>
|
<div class="notice" id="mine" style="display:none;margin:12px 0"></div>
|
||||||
<div class="ledger" id="rows"><div class="row"><span class="site">Loading…</span></div></div>
|
<div class="ledger" id="rows"><div class="row"><span class="site">Loading…</span></div></div>
|
||||||
<p class="small" style="margin-top:14px">Weeks are the last seven days, Central time. The company account is not listed.</p>
|
<p class="small" style="margin-top:14px">Weeks run Monday to Sunday, Central time. The company account is not listed.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="winners" style="display:none">
|
||||||
|
<h2>Last week’s winners</h2>
|
||||||
|
<div class="sub" id="winsub"></div>
|
||||||
|
<div class="ledger" id="winrows"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
@@ -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. <a href="/app">Open your board</a>.'; }
|
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. <a href="/app">Open your board</a>.'; }
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.p)));
|
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 = '<b>Weekly prizes:</b> ' + 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 => '<div class="row lb"><span><b class="num">' + medal(w.rank) + '</b> ' + esc(w.who) + '</span><span class="site">' + w.finds + ' finds</span><span class="pol">+' + w.prizePol + ' POL prize</span></div>').join(''); }
|
||||||
|
} catch (e) {}
|
||||||
const b = await (await fetch('/api/badges')).json();
|
const b = await (await fetch('/api/badges')).json();
|
||||||
document.getElementById('badges').innerHTML = b.badges.map(x => '<div class="card"><div style="font-size:34px">' + x.icon + '</div><h3>' + esc(x.name) + '</h3><p>' + esc(x.why) + '</p>' + (me && me.badges && me.badges.some(y => y.id === x.id) ? '<p style="margin-top:8px"><span class="tag done">Earned</span></p>' : '') + '</div>').join('');
|
document.getElementById('badges').innerHTML = b.badges.map(x => '<div class="card"><div style="font-size:34px">' + x.icon + '</div><h3>' + esc(x.name) + '</h3><p>' + esc(x.why) + '</p>' + (me && me.badges && me.badges.some(y => y.id === x.id) ? '<p style="margin-top:8px"><span class="tag done">Earned</span></p>' : '') + '</div>').join('');
|
||||||
show('week');
|
show('week');
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ async function notify(kind, p) {
|
|||||||
if (kind === 'paid') return telegram('\u{1F3AF} <b>PolHunter</b> · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got <b>' + fmt(p.pol) + ' POL</b> · <a href="' + explorer() + '/tx/' + p.tx + '">verify</a>\n<a href="' + SITE + '">Hunt yours</a>');
|
if (kind === 'paid') return telegram('\u{1F3AF} <b>PolHunter</b> · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got <b>' + fmt(p.pol) + ' POL</b> · <a href="' + explorer() + '/tx/' + p.tx + '">verify</a>\n<a href="' + SITE + '">Hunt yours</a>');
|
||||||
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
|
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
|
||||||
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error);
|
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error);
|
||||||
|
if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); }
|
||||||
}
|
}
|
||||||
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
|
// 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.';
|
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/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/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/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 === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
|
||||||
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.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 })) }); }
|
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() });
|
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/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/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/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
|
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)
|
// 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);
|
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'}`));
|
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'}`));
|
||||||
|
|||||||
+16
-1
@@ -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,
|
// 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
|
// 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 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 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 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');
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user