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 message = await response.text();
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
`, 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();
if (name) await api('/api/me/name', { name });
me = await session();
boot();
}
function showCreate() {
document.body.className = '';
renderTemplate(html`
New hand
`, 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))}
${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();