| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
- <title>Plan Maker 2000</title>
- <style>
- :root { --bg:#f6f6f6; --card:#fff; --ink:#111; --mute:#777; --line:#e5e5e5; --felt:#0d3b2e; --felt2:#0a2d23; --gold:#f2c14e; --chip:#e63946; --chip2:#ffd166; --jev:#7c5cff; }
- * { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
- body { margin:0; font-family:-apple-system, system-ui, sans-serif; background:var(--bg); color:var(--ink); transition: background .6s; }
- body.felt { background:var(--felt); color:#fff; }
- body.hot { background:var(--felt2); }
- body.crt { background:#000; }
- #app { max-width:480px; margin:0 auto; padding:16px; padding-bottom:120px; }
- h1 { font-size:22px; margin:8px 0 4px; }
- .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:10px 0; color:var(--ink); }
- body.felt .card { background:rgba(255,255,255,.06); border-color:rgba(255,255,255,.12); color:#fff; }
- .mute { color:var(--mute); font-size:13px; }
- body.felt .mute { color:rgba(255,255,255,.6); }
- input, button { font:inherit; }
- input { width:100%; padding:12px; border:1px solid var(--line); border-radius:10px; margin:6px 0; background:#fff; color:#111; }
- button { padding:12px 16px; border:0; border-radius:10px; background:#111; color:#fff; font-weight:600; width:100%; margin:6px 0; }
- button.ghost { background:transparent; border:1px solid var(--line); color:inherit; }
- button.small { width:auto; padding:8px 12px; margin:0 4px 0 0; font-size:14px; }
- button:disabled { opacity:.4; }
- .opt { display:flex; align-items:center; gap:10px; padding:12px; border-radius:12px; margin:8px 0; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.14); position:relative; overflow:hidden; }
- .opt .label { flex:1; font-weight:600; }
- .opt .odds { font-variant-numeric:tabular-nums; font-size:13px; min-width:44px; text-align:right; }
- .opt .bar { position:absolute; left:0; bottom:0; height:3px; background:var(--jev); transition:width .35s ease; }
- .opt .bar.wobble { animation:wob 1.2s infinite alternate ease-in-out; }
- @keyframes wob { from { opacity:.5; transform:scaleX(.96);} to { opacity:1; transform:scaleX(1.04);} }
- .opt.dead { opacity:.35; text-decoration:line-through; }
- .opt.more { border-style:dashed; }
- .chips { display:flex; gap:4px; flex-wrap:wrap; }
- .chip { width:16px; height:16px; border-radius:50%; background:var(--chip); border:2px solid #fff3; }
- .chip.other { background:var(--chip2); }
- .stack { display:flex; gap:6px; align-items:center; }
- .stack button { width:34px; height:34px; padding:0; border-radius:50%; background:#fff2; color:#fff; font-size:18px; margin:0; }
- .bank { display:flex; gap:4px; justify-content:center; margin:10px 0; }
- .bank .chip { width:22px; height:22px; }
- .members { display:flex; gap:6px; flex-wrap:wrap; font-size:12px; }
- .member { padding:4px 8px; border-radius:99px; background:rgba(255,255,255,.12); }
- .member.locked { background:var(--gold); color:#111; }
- .jev { display:flex; align-items:center; gap:8px; font-size:13px; margin-top:8px; }
- .jev .face { width:20px; height:20px; background:var(--jev); border-radius:4px; image-rendering:pixelated; }
- body.hot .jev .face { width:28px; height:28px; animation:bob .5s infinite alternate; }
- @keyframes bob { from { transform:translateY(0);} to { transform:translateY(-3px);} }
- .footer { position:fixed; left:0; right:0; bottom:0; padding:12px 16px calc(12px + env(safe-area-inset-bottom)); background:rgba(0,0,0,.35); backdrop-filter:blur(10px); }
- .footer .inner { max-width:480px; margin:0 auto; display:flex; gap:8px; }
- .lever { height:64px; font-size:20px; background:var(--gold); color:#111; border-radius:14px; }
- /* reveal */
- #reveal { position:fixed; inset:0; background:#000; color:#fff; display:none; flex-direction:column; align-items:center; justify-content:center; padding:24px; text-align:center; z-index:9; }
- #reveal.on { display:flex; }
- #reveal .scan { position:absolute; inset:0; background:repeating-linear-gradient(0deg, rgba(255,255,255,.04) 0 1px, transparent 1px 3px); pointer-events:none; }
- .reel { font-size:34px; font-weight:800; min-height:48px; letter-spacing:.5px; }
- .reel.shake { animation:shake .12s 4; }
- @keyframes shake { 0%{transform:translate(2px,-1px)} 50%{transform:translate(-2px,1px)} 100%{transform:translate(0)} }
- .winner { font-size:40px; font-weight:900; color:var(--gold); margin:10px 0; }
- .upset { color:#ff4d6d; font-weight:900; letter-spacing:2px; }
- .dealer { font-size:16px; opacity:.9; margin:8px 0 18px; max-width:340px; }
- .odds-list { font-size:13px; opacity:.7; }
- .reel-frame { border:2px solid var(--gold); border-radius:12px; padding:18px 24px; min-width:260px; }
- </style>
- </head>
- <body>
- <div id="app"></div>
- <div id="reveal"><div class="scan"></div><div id="reveal-body"></div></div>
- <script>
- const $ = s => document.querySelector(s);
- const handId = location.pathname.startsWith('/h/') ? location.pathname.split('/')[2] : null;
- let me = null, state = null, ws = null, revealed = null;
- const MORE = 'more_options';
- async function api(path, body) {
- const r = await fetch(path, { method: body ? 'POST' : 'GET', credentials:'include', headers:{'Content-Type':'application/json'}, body: body ? JSON.stringify(body) : undefined });
- if (!r.ok) { const t = await r.text(); toast(t); throw new Error(t); }
- return r.json();
- }
- function toast(t){ const d=document.createElement('div'); d.textContent=t; d.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(d); setTimeout(()=>d.remove(),1800); }
- async function session() {
- const r = await fetch('/api/auth/get-session', { credentials:'include' });
- const t = await r.text(); if (!t) return null;
- const j = JSON.parse(t); return j && j.user ? { id:j.user.id, name:j.user.name } : null;
- }
- // ---------- screens ----------
- function render() {
- if (!me) return renderJoin();
- if (!handId) return renderCreate();
- if (!state) return $('#app').innerHTML = '<p class="mute">Loading hand…</p>';
- renderHand();
- }
- function renderJoin() {
- document.body.className = '';
- $('#app').innerHTML = `
- <h1>Plan Maker 2000</h1>
- <div class="card">
- <p class="mute">Who are you at this table?</p>
- <input id="name" placeholder="Your name" autocomplete="off">
- <button onclick="joinAnon()">Jump in</button>
- <details><summary class="mute">Sign in with phone (OTP)</summary>
- <input id="phone" placeholder="+61…" inputmode="tel">
- <button class="ghost" onclick="sendOtp()">Send code</button>
- <input id="code" placeholder="6-digit code" inputmode="numeric" style="display:none">
- <button class="ghost" id="verify" onclick="verifyOtp()" style="display:none">Verify</button>
- </details>
- </div>`;
- }
- async function joinAnon() {
- const name = $('#name').value.trim() || 'anon';
- let s = await session();
- if (!s) { 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').style.display = 'block'; $('#verify').style.display = 'block'; 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 renderCreate() {
- document.body.className = '';
- $('#app').innerHTML = `
- <h1>New hand</h1>
- <div class="card">
- <input id="q" placeholder="Question (e.g. Friday dinner)">
- <input class="o" placeholder="Option 1"><input class="o" placeholder="Option 2"><input class="o" placeholder="Option 3 (optional)"><input class="o" placeholder="Option 4 (optional)">
- <input id="con" placeholder="Constraints, comma separated (optional)">
- <button onclick="createHand()">Open the table</button>
- <p class="mute">Screenshot intake comes later; type it for now.</p>
- </div>`;
- }
- async function createHand() {
- const options = [...document.querySelectorAll('.o')].map(i=>i.value.trim()).filter(Boolean);
- const constraints = $('#con').value.split(',').map(s=>s.trim()).filter(Boolean);
- const h = await api('/api/hands', { question: $('#q').value.trim(), options, constraints });
- location.href = '/h/' + h.id;
- }
- function label(id){ return (state.round.options.find(o=>o.id===id)||{}).label || id; }
- function mine(){ return state.round.chips[me.id] || {}; }
- function spent(){ return Object.values(mine()).reduce((a,b)=>a+b,0); }
- function totals(){ const t={}; for (const c of Object.values(state.round.chips)) for (const [o,n] of Object.entries(c)) t[o]=(t[o]||0)+n; return t; }
- function renderHand() {
- const r = state.round, odds = r.odds, 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(m=>m.id===me.id);
- const left = state.chipsPerMember - spent();
- const vetoLeft = state.vetoesPerMember - (state.vetoesUsed[me.id]||0);
- const t = totals();
- const opts = r.options.map(o => {
- const dead = !live.has(o.id);
- const p = odds && odds.probabilities[o.id] != null ? odds.probabilities[o.id] : null;
- const n = mine()[o.id]||0, others = (t[o.id]||0) - n;
- return `<div class="opt ${dead?'dead':''} ${o.id===MORE?'more':''}">
- <div style="flex:1">
- <div class="label">${o.label}</div>
- <div class="chips">${'<span class="chip"></span>'.repeat(n)}${'<span class="chip other"></span>'.repeat(Math.max(0,others))}</div>
- </div>
- ${state.status==='open' && !dead && meIn && !meIn.locked ? `<div class="stack"><button onclick="place('${o.id}',-1)">−</button><button onclick="place('${o.id}',1)">+</button></div>` : ''}
- <div class="odds">${p==null?'—':Math.round(p*100)+'%'}</div>
- ${state.status==='open' && !dead && o.id!==MORE && meIn && !meIn.locked && vetoLeft>0 ? `<button class="small ghost" style="color:#ff8fa3" onclick="veto('${o.id}')">veto</button>` : ''}
- <div class="bar ${odds && odds.confidence<0.5?'wobble':''}" style="width:${p==null?0:Math.round(p*100)}%"></div>
- </div>`;
- }).join('');
- const members = state.members.map(m=>`<span class="member ${m.locked?'locked':''}">${m.name}${m.locked?' ✓':''}${odds && odds.sandbagging[m.id]>0.6?' 🫥':''}</span>`).join('');
- 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';
- let footer = '';
- if (state.status==='open') footer = meIn && !meIn.locked
- ? `<button class="ghost" onclick="act({op:'lock'})">Lock in (${left} left)</button><button class="lever" onclick="act({op:'pull'})">PULL</button>`
- : `<button class="lever" onclick="act({op:'pull'})">PULL THE LEVER</button>`;
- if (state.status==='posted') footer = `<button class="ghost" onclick="act({op:'again'})">Go again</button><button class="ghost" onclick="more()">More options</button><button onclick="act({op:'accept'})">Accept</button>`;
- if (state.status==='accepted') footer = `<button onclick="location.href='/'">New hand</button>`;
- $('#app').innerHTML = `
- <div class="mute">Round ${r.n} · ${state.status}</div>
- <h1>${state.question}</h1>
- <div class="members">${members}</div>
- ${!meIn ? `<button onclick="act({op:'join'})">Sit down as ${me.name}</button>` : ''}
- <div class="bank">${'<span class="chip"></span>'.repeat(Math.max(0,left))}<span class="mute" style="margin-left:8px">${left} chips · ${vetoLeft} vetoes</span></div>
- ${opts}
- <div class="jev"><div class="face"></div><div>${jevLine}</div></div>
- ${r.pull && state.status!=='open' ? `<div class="card"><b>${label(r.pull.winner)}</b> — ${Math.round(r.pull.odds[r.pull.winner]*100)}% at the pull<br><span class="mute">${r.pull.dealer}</span><br><button class="ghost small" onclick="playReveal(state.round.pull)">replay</button></div>` : ''}
- ${state.rounds.length>1 ? `<div class="mute">Earlier: ${state.rounds.slice(0,-1).map(x=>x.pull?label(x.pull.winner):'?').join(' → ')}</div>` : ''}
- <div class="footer"><div class="inner">${footer}</div></div>`;
- }
- async function act(body){ await api(`/api/hands/${handId}/act`, body); }
- async function place(o,n){ await act({op:'place', option:o, n}); }
- async function veto(o){ if (confirm('Veto '+label(o)+'? Everyone will see.')) await act({op:'veto', option:o}); }
- async function more(){ const l = prompt('Options agent not wired yet — type new options, comma separated'); if (!l) return; await act({op:'more', labels: l.split(',').map(s=>s.trim()).filter(Boolean)}); }
- // ---------- reveal: reels with the near-miss ----------
- function playReveal(pull) {
- const el = $('#reveal'), body = $('#reveal-body');
- el.classList.add('on'); document.body.classList.add('crt');
- const seq = pull.order; // ..., runner-up, winner
- const pw = pull.odds[pull.winner];
- let i = 0;
- body.innerHTML = `<div class="mute">The lever has spoken</div><div class="reel-frame"><div class="reel" id="reel">…</div></div>`;
- const reel = $('#reel');
- const step = () => {
- if (i < seq.length) {
- reel.textContent = label(seq[i]);
- const isRunnerUp = seq[i]===pull.runnerUp && i===seq.length-2;
- const delay = i===seq.length-1 ? 0 : isRunnerUp ? 900 : Math.max(120, 420 - i*60); // hang on the runner-up: the near-miss
- if (isRunnerUp) reel.classList.add('shake');
- i++; setTimeout(step, delay);
- } else {
- body.innerHTML = `${pw<0.3?'<div class="upset">UPSET</div>':''}<div class="mute">${state.question}</div><div class="winner">${label(pull.winner)}</div>
- <div class="mute">${Math.round(pw*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(([k,v])=>label(k)+' '+Math.round(v*100)+'%').join(' · ')}</div>
- <button class="ghost" style="margin-top:20px;width:auto" onclick="closeReveal()">ok</button>`;
- if (navigator.vibrate) navigator.vibrate([30,40,30,40,120]);
- }
- };
- setTimeout(step, 600);
- }
- function closeReveal(){ $('#reveal').classList.remove('on'); document.body.classList.remove('crt'); render(); }
- // ---------- socket ----------
- function connect() {
- const proto = location.protocol==='https:' ? 'wss' : 'ws';
- ws = new WebSocket(`${proto}://${location.host}/api/hands/${handId}/ws`);
- ws.onmessage = ev => {
- if (ev.data==='pong') return;
- const m = JSON.parse(ev.data);
- if (m.type==='state') {
- const prevPull = state && state.round && state.round.pull ? state.round.pull.at : null;
- state = m.state;
- const p = state.round.pull;
- if (p && p.at !== prevPull && p.at !== revealed) { revealed = p.at; playReveal(p); return; }
- render();
- }
- if (m.type==='odds') { if (state) { state.round.odds = m.odds; render(); } }
- if (m.type==='error') toast(m.error.slice(0,120));
- };
- ws.onclose = () => setTimeout(connect, 1500);
- setInterval(()=>{ try{ ws.send('ping'); }catch{} }, 25000);
- }
- async function boot() {
- me = await session();
- if (me && handId) {
- state = await api(`/api/hands/${handId}`);
- if (!state.members.find(m=>m.id===me.id)) await act({op:'join'});
- if (!ws) connect();
- }
- render();
- }
- boot();
- </script>
- </body>
- </html>
|