| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381 |
- 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`<p class="mute">Loading hand…</p>`, app);
- showHand();
- }
- function showJoin() {
- document.body.className = '';
- renderTemplate(html`
- <h1>Plan Maker 2000</h1>
- <div class="card">
- <p class="mute">Who are you at this table?</p>
- <input id="name" aria-label="Your name" placeholder="Your name" autocomplete="name">
- <button @click=${joinAnon}>Jump in</button>
- <details>
- <summary class="mute">Sign in with phone (OTP)</summary>
- <input id="phone" aria-label="Phone number" placeholder="+61…" inputmode="tel" autocomplete="tel">
- <button class="ghost" @click=${sendOtp}>Send code</button>
- <input id="code" aria-label="Six-digit code" placeholder="6-digit code" inputmode="numeric" autocomplete="one-time-code" hidden>
- <button class="ghost" id="verify" @click=${verifyOtp} hidden>Verify</button>
- </details>
- </div>
- `, 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`
- <h1>New hand</h1>
- <div class="card">
- <input id="q" aria-label="Question" placeholder="Question (e.g. Friday dinner)">
- <input class="o" aria-label="Option 1" placeholder="Option 1">
- <input class="o" aria-label="Option 2" placeholder="Option 2">
- <input class="o" aria-label="Option 3" placeholder="Option 3 (optional)">
- <input class="o" aria-label="Option 4" placeholder="Option 4 (optional)">
- <input id="con" aria-label="Constraints" placeholder="Constraints, comma separated (optional)">
- <button @click=${createHand}>Open the table</button>
- <p class="mute">Screenshot intake comes later; type it for now.</p>
- </div>
- `, 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`<span class="chip ${other ? 'other' : ''}" aria-hidden="true"></span>`);
- }
- 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`
- <div class=${classes}>
- <div style="flex:1">
- <div class="label">${option.label}</div>
- <div class="chips" aria-label=${`${myChips} of your chips and ${Math.max(0, otherChips)} other chips`}>
- ${chipList(myChips)}${chipList(otherChips, true)}
- </div>
- </div>
- ${editable ? html`
- <div class="stack">
- <button aria-label=${`Remove a chip from ${option.label}`} @click=${() => place(option.id, -1)}>−</button>
- <button aria-label=${`Add a chip to ${option.label}`} @click=${() => place(option.id, 1)}>+</button>
- </div>
- ` : null}
- <div class="odds">${probability == null ? '—' : `${percentage}%`}</div>
- ${editable && option.id !== MORE && vetoLeft > 0 ? html`
- <button class="small ghost" style="color:#ff8fa3" @click=${() => veto(option.id)}>veto</button>
- ` : null}
- <div class="bar ${odds && odds.confidence < 0.5 ? 'wobble' : ''}" style=${`width:${percentage}%`}></div>
- </div>
- `;
- }
- 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`<button class="ghost" disabled>${message}</button>`;
- }
- const label = waitingFor.length ? `Lock in (${left} left)` : 'LOCK & PULL THE LEVER';
- return html`<button class=${waitingFor.length ? 'ghost' : 'lever'} @click=${() => act({ op: 'lock' })}>${label}</button>`;
- }
- if (state.status === 'posted') {
- return html`<button class="ghost" @click=${() => act({ op: 'again' })}>Go again</button><button class="ghost" @click=${more}>More options</button><button @click=${() => act({ op: 'accept' })}>Accept</button>`;
- }
- if (state.status === 'accepted') return html`<button @click=${() => { location.href = '/'; }}>New hand</button>`;
- 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`
- <div class="mute">Round ${round.n} · ${state.status}</div>
- <h1>${state.question}</h1>
- <div class="members" aria-label="People at the table">
- ${state.members.map((member) => html`<span class="member ${member.locked ? 'locked' : ''}">${member.name}${member.locked ? ' ✓' : ''}${odds && odds.sandbagging[member.id] > 0.6 ? ' 🫥' : ''}</span>`)}
- </div>
- ${!meIn ? html`<button @click=${() => act({ op: 'join' })}>Sit down as ${me.name}</button>` : null}
- <div class="bank">${chipList(left)}<span class="mute" style="margin-left:8px">${left} chips · ${vetoLeft} vetoes</span></div>
- ${round.options.map((option) => optionView(option, live, odds, meIn, vetoLeft, chipTotals))}
- <div class="jev"><div class="face" aria-hidden="true"></div><div>${jevLine}</div></div>
- ${round.pull && state.status !== 'open' ? html`
- <div class="card">
- <b>${label(round.pull.winner)}</b> — ${Math.round(round.pull.odds[round.pull.winner] * 100)}% at the pull<br>
- <span class="mute">${round.pull.dealer}</span><br>
- <button class="ghost small" @click=${() => playReveal(state.round.pull)}>replay</button>
- </div>
- ` : null}
- ${state.rounds.length > 1 ? html`<div class="mute">Earlier: ${state.rounds.slice(0, -1).map((item) => item.pull ? label(item.pull.winner) : '?').join(' → ')}</div>` : null}
- <div class="footer"><div class="inner">${footerView(meIn, left)}</div></div>
- `, 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`<div class="mute">The lever has spoken</div><div class="reel-frame"><div class="reel" id="reel" aria-live="polite">…</div></div>`, 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`<div class="upset">UPSET</div>` : null}
- <div class="mute">${state.question}</div>
- <div class="winner">${label(pull.winner)}</div>
- <div class="mute">${Math.round(winnerProbability * 100)}% at the pull</div>
- <div class="dealer">${pull.dealer}</div>
- <div class="odds-list">${Object.entries(pull.odds).sort((a, b) => b[1] - a[1]).map(([key, value]) => `${label(key)} ${Math.round(value * 100)}%`).join(' · ')}</div>
- <button class="ghost" style="margin-top:20px;width:auto" @click=${closeReveal}>ok</button>
- `, 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();
|