523d57624e
Hand-off sign-in from InstantAdPay (lib/sso.js: HMAC token, five minutes, single use; no sign-up, no mailer), missions with per-member per-visit proof codes (lib/missions.js: the embed is answered only from the mission's own origin, only after the dwell; hiding place rotates by day and member), weighted-low rewards with a daily cap that queues rather than refuses (lib/rewards.js), the faucet sender on ethers with a low-balance alert (lib/faucet.js), the one-line embed for the sites (public/embed.js), the hunter board, the public ledger, an admin page, and the site's own look. Telegram is behind the OUTBOUND gate like everything else. 13-check end-to-end test in test/run.js. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
61 lines
4.8 KiB
JavaScript
61 lines
4.8 KiB
JavaScript
// The hunter's board: missions in their own order, a live timer while they are on the site, a
|
|
// code box when they are back. One open mission at a time.
|
|
(() => {
|
|
const $ = id => document.getElementById(id);
|
|
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
const api = async (path, body) => { const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}); const j = await r.json().catch(() => ({})); if (r.status === 401) location.href = '/?signin=1'; return j; };
|
|
let board = null, open = null; // open: { missionId, token, url, dwell, started }
|
|
|
|
function card(m) {
|
|
const isOpen = open && open.missionId === m.id;
|
|
const r = m.reward || {};
|
|
return '<div class="card' + (isOpen ? ' mission-open' : '') + '" data-id="' + esc(m.id) + '">'
|
|
+ '<span class="tag' + (m.done ? ' done' : '') + '">' + (m.done ? 'Completed' : esc(m.site)) + '</span>'
|
|
+ '<h3>' + esc(m.name) + '</h3><p>' + esc(m.brief) + '</p>'
|
|
+ '<p style="margin-top:10px"><span class="range">' + r.minPol + ' to ' + r.maxPol + ' POL</span> <span style="color:var(--dim);font-size:12px">· ' + m.dwell + 's on the site</span></p>'
|
|
+ (m.done ? '' : isOpen
|
|
? '<div class="timer" id="timer">Your code appears on the site after <b>' + m.dwell + 's</b>. Keep that tab open.</div>'
|
|
+ '<div class="codebox"><input id="code" maxlength="6" placeholder="CODE" autocomplete="off" spellcheck="false"><button class="btn pol" id="submit">Claim</button></div>'
|
|
+ '<div class="msg" id="msg"></div>'
|
|
+ '<p style="margin-top:8px"><a href="' + esc(open.url) + '" target="_blank" rel="noopener">Open the site again ↗</a></p>'
|
|
: '<div style="margin-top:14px"><button class="btn" data-start="' + esc(m.id) + '">Start mission</button></div>')
|
|
+ '</div>';
|
|
}
|
|
function render() {
|
|
$('who').innerHTML = '<i></i> ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + (board.me.wallet ? '' : ' · no wallet linked');
|
|
const r = board.missions[0] && board.missions[0].reward;
|
|
if (r) $('rangeLine').textContent = 'Each find pays a random drip between ' + r.minPol + ' and ' + r.maxPol + ' POL. Your codes are yours alone.';
|
|
$('missions').innerHTML = board.missions.length ? board.missions.map(card).join('') : '<div class="card"><p>No missions are open right now. Check back soon.</p></div>';
|
|
$('mine').innerHTML = board.drips.length ? board.drips.map(d => '<div class="row"><span>' + esc(d.site) + '</span><span class="site">' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'failed: ' + esc(d.error || '') }[d.status] || d.status) + '</span><span class="pol">+' + d.pol + ' POL</span>' + (d.tx ? '<a class="when" target="_blank" rel="noopener" href="' + explorer + '/tx/' + d.tx + '">verify ↗</a>' : '<span class="when"></span>') + '</div>').join('')
|
|
: '<div class="row"><span class="site">Nothing yet. Your first find goes here.</span></div>';
|
|
document.querySelectorAll('[data-start]').forEach(b => b.addEventListener('click', () => start(b.dataset.start)));
|
|
const s = $('submit'); if (s) { s.addEventListener('click', submit); $('code').addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); tick(); }
|
|
}
|
|
let explorer = 'https://polygonscan.com';
|
|
async function load() { board = await api('/api/my/board'); render(); }
|
|
async function start(id) {
|
|
const r = await api('/api/my/start', { missionId: id });
|
|
if (r.error) { alert(r.error); return; }
|
|
open = { missionId: id, token: r.token, url: r.url, dwell: r.dwell, started: Date.now(), expires: r.expires };
|
|
window.open(r.url, '_blank', 'noopener');
|
|
render();
|
|
}
|
|
function tick() {
|
|
const el = $('timer'); if (!el || !open) return;
|
|
const left = Math.max(0, open.dwell - Math.floor((Date.now() - open.started) / 1000));
|
|
el.innerHTML = left > 0 ? 'Your code appears on the site in about <b>' + left + 's</b>. Keep that tab open, then come back and type it here.' : 'Your code should be showing on the site now. Type it here.';
|
|
if (open.expires && Date.now() > open.expires) { el.innerHTML = 'This attempt timed out. Start the mission again.'; return; }
|
|
setTimeout(tick, 1000);
|
|
}
|
|
async function submit() {
|
|
const code = $('code').value.trim(); if (!code) return;
|
|
$('submit').disabled = true;
|
|
const r = await api('/api/my/submit', { token: open.token, code });
|
|
const m = $('msg'); m.className = 'msg ' + (r.error ? 'bad' : 'ok'); m.textContent = r.error || r.message;
|
|
$('submit').disabled = false;
|
|
if (!r.error) { open = null; setTimeout(load, 900); }
|
|
}
|
|
fetch('/api/config').then(r => r.json()).then(c => { explorer = c.explorer || explorer; }).catch(() => {});
|
|
load();
|
|
})();
|