import { html, render as renderTemplate } from 'lit-html'; const $ = (selector) => document.querySelector(selector); const app = $('#app'); const reveal = $('#reveal'); const revealBody = $('#reveal-body'); const handId = location.pathname.startsWith('/h/') ? location.pathname.split('/')[2] : null; const MORE = 'more_options'; let me = null; let state = null; let ws = null; let revealed = null; let reconnectTimer = null; let pingTimer = null; let revealTimers = []; async function api(path, body) { const response = await fetch(path, { method: body ? 'POST' : 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { const text = await response.text(); let message = text; try { message = JSON.parse(text).error || text; } catch {} toast(message); throw new Error(message); } return response.json(); } function toast(message) { const element = document.createElement('div'); element.textContent = message; element.setAttribute('role', 'status'); element.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);background:#111;color:#fff;padding:8px 14px;border-radius:99px;font-size:13px;z-index:20'; document.body.appendChild(element); setTimeout(() => element.remove(), 1800); } async function session() { const response = await fetch('/api/auth/get-session', { credentials: 'include' }); const text = await response.text(); if (!text) return null; const result = JSON.parse(text); return result?.user ? { id: result.user.id, name: result.user.name } : null; } function show() { if (!me) return showJoin(); if (!handId) return showCreate(); if (!state) return renderTemplate(html`

Loading hand…

`, app); showHand(); } function showJoin() { document.body.className = ''; renderTemplate(html`

Plan Maker 2000

Who are you at this table?

Sign in with phone (OTP)
`, app); } async function joinAnon() { const name = $('#name').value.trim() || 'anon'; const current = await session(); if (!current) { await fetch('/api/auth/sign-in/anonymous', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}', }); } await api('/api/me/name', { name }); me = await session(); boot(); } async function sendOtp() { await api('/api/auth/phone-number/send-otp', { phoneNumber: $('#phone').value.trim() }); $('#code').hidden = false; $('#verify').hidden = false; toast('Code sent (check wrangler console in dev)'); } async function verifyOtp() { await api('/api/auth/phone-number/verify', { phoneNumber: $('#phone').value.trim(), code: $('#code').value.trim(), }); const name = $('#name').value.trim() || 'anon'; await api('/api/me/name', { name }); me = await session(); boot(); } function showCreate() { document.body.className = ''; renderTemplate(html`

New hand

Screenshot intake comes later; type it for now.

`, app); } async function createHand() { const options = [...document.querySelectorAll('.o')].map((input) => input.value.trim()).filter(Boolean); const constraints = $('#con').value.split(',').map((value) => value.trim()).filter(Boolean); const hand = await api('/api/hands', { question: $('#q').value.trim(), options, constraints }); location.href = `/h/${hand.id}`; } function label(id) { return state.round.options.find((option) => option.id === id)?.label || id; } function mine() { return state.round.chips[me.id] || {}; } function spent() { return Object.values(mine()).reduce((sum, count) => sum + count, 0); } function totals() { const result = {}; for (const chips of Object.values(state.round.chips)) { for (const [option, count] of Object.entries(chips)) result[option] = (result[option] || 0) + count; } return result; } function chipList(count, other = false) { return Array.from({ length: Math.max(0, count) }, () => html``); } function optionView(option, live, odds, meIn, vetoLeft, chipTotals) { const dead = !live.has(option.id); const probability = odds?.probabilities[option.id] ?? null; const myChips = mine()[option.id] || 0; const otherChips = (chipTotals[option.id] || 0) - myChips; const editable = state.status === 'open' && !dead && meIn && !meIn.locked; const classes = `opt ${dead ? 'dead' : ''} ${option.id === MORE ? 'more' : ''}`; const percentage = probability == null ? 0 : Math.round(probability * 100); return html`
${option.label}
${chipList(myChips)}${chipList(otherChips, true)}
${editable ? html`
` : null}
${probability == null ? '—' : `${percentage}%`}
${editable && option.id !== MORE && vetoLeft > 0 ? html` ` : null}
`; } function footerView(meIn, left) { if (state.status === 'open') { if (!meIn) return null; const waitingFor = state.members.filter((member) => member.id !== me.id && !member.locked); if (meIn.locked) { const message = waitingFor.length ? `Waiting for ${waitingFor.map((member) => member.name).join(', ')}…` : 'The lever is turning…'; return html``; } const label = waitingFor.length ? `Lock in (${left} left)` : 'LOCK & PULL THE LEVER'; return html``; } if (state.status === 'posted') { return html``; } if (state.status === 'accepted') return html``; return null; } function showHand() { const round = state.round; const odds = round.odds; const live = new Set(state.live); const spice = odds ? odds.spice : 0; document.body.className = state.status === 'open' ? (spice >= 2 ? 'felt hot' : 'felt') : 'felt'; const meIn = state.members.find((member) => member.id === me.id); const left = state.chipsPerMember - spent(); const vetoLeft = state.vetoesPerMember - (state.vetoesUsed[me.id] || 0); const chipTotals = totals(); const jevLine = odds ? `Jev · ${Math.round(odds.confidence * 100)}% sure · ${['mild', 'warm', 'hot', 'volcanic'][Math.round(odds.spice)]} · ${odds.contentious > 0.7 ? `contentious (${odds.about})` : 'calm'} · ${odds.format} · ${odds.ms}ms` : 'Jev is waiting for chips'; renderTemplate(html`
Round ${round.n} · ${state.status}

${state.question}

${state.members.map((member) => html`${member.name}${member.locked ? ' ✓' : ''}${odds && odds.sandbagging[member.id] > 0.6 ? ' 🫥' : ''}`)}
${!meIn ? html`` : null}
${chipList(left)}${left} chips · ${vetoLeft} vetoes
${round.options.map((option) => optionView(option, live, odds, meIn, vetoLeft, chipTotals))}
${jevLine}
${round.pull && state.status !== 'open' ? html`
${label(round.pull.winner)} — ${Math.round(round.pull.odds[round.pull.winner] * 100)}% at the pull
${round.pull.dealer}
` : null} ${state.rounds.length > 1 ? html`
Earlier: ${state.rounds.slice(0, -1).map((item) => item.pull ? label(item.pull.winner) : '?').join(' → ')}
` : null} `, app); } async function act(body) { await api(`/api/hands/${handId}/act`, body); } async function place(option, amount) { await act({ op: 'place', option, n: amount }); } async function veto(option) { if (confirm(`Veto ${label(option)}? Everyone will see.`)) await act({ op: 'veto', option }); } async function more() { const labels = prompt('Options agent not wired yet — type new options, comma separated'); if (!labels) return; await act({ op: 'more', labels: labels.split(',').map((value) => value.trim()).filter(Boolean) }); } function clearRevealTimers() { for (const timer of revealTimers) clearTimeout(timer); revealTimers = []; } function afterRevealDelay(fn, delay) { revealTimers.push(setTimeout(fn, delay)); } function playReveal(pull) { clearRevealTimers(); reveal.classList.add('on'); document.body.classList.add('crt'); const sequence = pull.order; const winnerProbability = pull.odds[pull.winner]; let index = 0; renderTemplate(html`
The lever has spoken
…
`, revealBody); const reel = $('#reel'); const step = () => { if (index < sequence.length) { reel.textContent = label(sequence[index]); const runnerUp = sequence[index] === pull.runnerUp && index === sequence.length - 2; const delay = index === sequence.length - 1 ? 0 : runnerUp ? 900 : Math.max(120, 420 - index * 60); if (runnerUp) reel.classList.add('shake'); index += 1; afterRevealDelay(step, delay); return; } renderTemplate(html` ${winnerProbability < 0.3 ? html`
UPSET
` : null}
${state.question}
${label(pull.winner)}
${Math.round(winnerProbability * 100)}% at the pull
${pull.dealer}
${Object.entries(pull.odds).sort((a, b) => b[1] - a[1]).map(([key, value]) => `${label(key)} ${Math.round(value * 100)}%`).join(' · ')}
`, revealBody); revealBody.querySelector('button')?.focus(); if (navigator.vibrate) navigator.vibrate([30, 40, 30, 40, 120]); }; afterRevealDelay(step, 600); } function closeReveal() { clearRevealTimers(); reveal.classList.remove('on'); document.body.classList.remove('crt'); show(); } document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && reveal.classList.contains('on')) closeReveal(); }); function scheduleReconnect() { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, 1500); } function connect() { clearTimeout(reconnectTimer); if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; clearInterval(pingTimer); const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const socket = new WebSocket(`${proto}://${location.host}/api/hands/${handId}/ws`); ws = socket; socket.onopen = () => { pingTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.send('ping'); }, 25000); }; socket.onmessage = (event) => { if (event.data === 'pong') return; const message = JSON.parse(event.data); if (message.type === 'state') { const previousPull = state?.round?.pull?.at ?? null; state = message.state; const pull = state.round.pull; if (pull && pull.at !== previousPull && pull.at !== revealed) { revealed = pull.at; playReveal(pull); return; } show(); } if (message.type === 'odds' && state) { state.round.odds = message.odds; show(); } if (message.type === 'error') toast(message.error.slice(0, 120)); }; socket.onclose = () => { if (ws !== socket) return; ws = null; clearInterval(pingTimer); pingTimer = null; scheduleReconnect(); }; } async function boot() { me = await session(); if (me && handId) { state = await api(`/api/hands/${handId}`); if (!state.members.find((member) => member.id === me.id)) await act({ op: 'join' }); connect(); } show(); } boot();