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 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-20 05:04:24 -05:00
parent 84a923326e
commit 7b061480cb
10 changed files with 75 additions and 24 deletions
+27 -3
View File
@@ -5,6 +5,27 @@
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 }
// toasts: small, stacked, gone in a few seconds
function toast(msg, kind, link) {
let host = $('toasts'); if (!host) { host = document.createElement('div'); host.id = 'toasts'; document.body.appendChild(host); }
const t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.innerHTML = msg + (link ? ' <a href="' + esc(link.href) + '" target="_blank" rel="noopener">' + esc(link.text) + '</a>' : '');
host.appendChild(t); requestAnimationFrame(() => t.classList.add('in'));
setTimeout(() => { t.classList.remove('in'); setTimeout(() => t.remove(), 400); }, kind === 'big' ? 9000 : 6000);
}
const seenPaid = new Set(); let seenLedger = null;
// a drip that flips to paid while the board is open gets a toast with its proof
function watchPaid() {
for (const d of (board.drips || [])) if (d.status === 'paid' && d.tx) { if (seenPaid.size && !seenPaid.has(d.id)) toast('\u{1F4B0} <b>' + d.pol + ' POL</b> landed in your wallet.', 'ok', { href: explorer + '/tx/' + d.tx, text: 'verify \u2197' }); seenPaid.add(d.id); }
if (!seenPaid.size) for (const d of (board.drips || [])) if (d.status === 'paid') seenPaid.add(d.id);
}
// other hunters' finds, as they happen
async function watchLedger() {
try { const r = await (await fetch('/api/ledger')).json(); const rows = r.recent || [];
if (seenLedger) { const fresh = rows.filter(x => !seenLedger.has(x.tx)).slice(0, 2); for (const x of fresh) if (!board || !board.me || x.who !== (board.me.username ? '@' + board.me.username : '#' + board.me.memberId)) toast('\u{1F3AF} ' + esc(x.who) + ' just found <b>' + x.pol + ' POL</b> on ' + esc(x.site) + '.', ''); }
seenLedger = new Set(rows.map(x => x.tx));
} catch (e) {}
}
setInterval(watchLedger, 45000); setInterval(async () => { if (board && (board.drips || []).some(d => d.status === 'due' || d.status === 'sent')) { await load(); } }, 30000);
function card(m) {
const isOpen = open && open.missionId === m.id;
@@ -13,7 +34,7 @@
+ '<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 ? '' : (board.pool && board.pool.spent && !isOpen) ? '<div style="margin-top:14px"><span class="tag gold">Closed until midnight Central</span></div>' : isOpen
+ (m.done ? '' : (board.pool && board.pool.spent && !isOpen) ? '<div style="margin-top:14px"><span class="tag gold">Closed until midnight Central</span></div>' : (board.daily && !board.daily.left && !isOpen) ? '<div style="margin-top:14px"><span class="tag">Back tomorrow</span></div>' : 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>'
@@ -78,6 +99,8 @@
const copy = async (el, btn) => { try { await navigator.clipboard.writeText(el.value); } catch (e) { el.select(); document.execCommand('copy'); } const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t; }, 1200); };
$('copylink').onclick = () => copy($('mylink'), $('copylink')); $('copytext').onclick = () => copy($('sharetext'), $('copytext'));
}
watchPaid(); if (seenLedger === null) watchLedger();
const dl = board.daily; if (dl && $('rangeLine')) $('rangeLine').innerHTML = ($('rangeLine').textContent.replace(/\s*·\s*You have.*$/, '')) + ' \u00b7 ' + (dl.left ? 'You have <b>' + dl.left + ' of ' + dl.limit + '</b> missions left today.' : '<b>That is your ' + dl.limit + ' for today.</b> Fresh missions at midnight Central.');
$('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: 'held, being looked at' }[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)));
@@ -87,7 +110,7 @@
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) { if (r.spent) { await load(); } else alert(r.error); return; }
if (r.error) { toast(esc(r.error), 'bad'); if (r.spent || r.dailyDone) await load(); 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();
@@ -104,9 +127,10 @@
$('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;
if (r.error) toast(esc(r.error), 'bad'); else toast('\u{1F389} <b>Found it!</b> ' + r.pol + ' POL is on its way to your wallet.', 'big');
if (!r.error) { const a = document.createElement('a'); a.href = '#share'; a.textContent = ' Share this find \u2197'; a.style.marginLeft = '6px'; m.appendChild(a); delete $('sharetext').dataset.touched; }
// an achievement unlocked by this find: celebrate it once the board has reloaded
if (!r.error && r.unlocked && r.unlocked.length) { const id = r.unlocked[0]; setTimeout(async () => { await load(); shareBadge(id, true); }, 1200); }
if (!r.error && r.unlocked && r.unlocked.length) { const id = r.unlocked[0]; toast('\u{1F3C6} <b>Achievement unlocked!</b>', 'big'); setTimeout(async () => { await load(); shareBadge(id, true); }, 1200); }
$('submit').disabled = false;
if (!r.error || r.spent) { open = null; setTimeout(load, 900); }
}