index.html 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
  6. <title>Plan Maker 2000</title>
  7. <style>
  8. :root { --bg:#f6f6f6; --card:#fff; --ink:#111; --mute:#777; --line:#e5e5e5; --felt:#0d3b2e; --felt2:#0a2d23; --gold:#f2c14e; --chip:#e63946; --chip2:#ffd166; --jev:#7c5cff; }
  9. * { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
  10. body { margin:0; font-family:-apple-system, system-ui, sans-serif; background:var(--bg); color:var(--ink); transition: background .6s; }
  11. body.felt { background:var(--felt); color:#fff; }
  12. body.hot { background:var(--felt2); }
  13. body.crt { background:#000; }
  14. #app { max-width:480px; margin:0 auto; padding:16px; padding-bottom:120px; }
  15. h1 { font-size:22px; margin:8px 0 4px; }
  16. .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:10px 0; color:var(--ink); }
  17. body.felt .card { background:rgba(255,255,255,.06); border-color:rgba(255,255,255,.12); color:#fff; }
  18. .mute { color:var(--mute); font-size:13px; }
  19. body.felt .mute { color:rgba(255,255,255,.6); }
  20. input, button { font:inherit; }
  21. input { width:100%; padding:12px; border:1px solid var(--line); border-radius:10px; margin:6px 0; background:#fff; color:#111; }
  22. button { padding:12px 16px; border:0; border-radius:10px; background:#111; color:#fff; font-weight:600; width:100%; margin:6px 0; }
  23. button.ghost { background:transparent; border:1px solid var(--line); color:inherit; }
  24. button.small { width:auto; padding:8px 12px; margin:0 4px 0 0; font-size:14px; }
  25. button:disabled { opacity:.4; }
  26. .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; }
  27. .opt .label { flex:1; font-weight:600; }
  28. .opt .odds { font-variant-numeric:tabular-nums; font-size:13px; min-width:44px; text-align:right; }
  29. .opt .bar { position:absolute; left:0; bottom:0; height:3px; background:var(--jev); transition:width .35s ease; }
  30. .opt .bar.wobble { animation:wob 1.2s infinite alternate ease-in-out; }
  31. @keyframes wob { from { opacity:.5; transform:scaleX(.96);} to { opacity:1; transform:scaleX(1.04);} }
  32. .opt.dead { opacity:.35; text-decoration:line-through; }
  33. .opt.more { border-style:dashed; }
  34. .chips { display:flex; gap:4px; flex-wrap:wrap; }
  35. .chip { width:16px; height:16px; border-radius:50%; background:var(--chip); border:2px solid #fff3; }
  36. .chip.other { background:var(--chip2); }
  37. .stack { display:flex; gap:6px; align-items:center; }
  38. .stack button { width:34px; height:34px; padding:0; border-radius:50%; background:#fff2; color:#fff; font-size:18px; margin:0; }
  39. .bank { display:flex; gap:4px; justify-content:center; margin:10px 0; }
  40. .bank .chip { width:22px; height:22px; }
  41. .members { display:flex; gap:6px; flex-wrap:wrap; font-size:12px; }
  42. .member { padding:4px 8px; border-radius:99px; background:rgba(255,255,255,.12); }
  43. .member.locked { background:var(--gold); color:#111; }
  44. .jev { display:flex; align-items:center; gap:8px; font-size:13px; margin-top:8px; }
  45. .jev .face { width:20px; height:20px; background:var(--jev); border-radius:4px; image-rendering:pixelated; }
  46. body.hot .jev .face { width:28px; height:28px; animation:bob .5s infinite alternate; }
  47. @keyframes bob { from { transform:translateY(0);} to { transform:translateY(-3px);} }
  48. .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); }
  49. .footer .inner { max-width:480px; margin:0 auto; display:flex; gap:8px; }
  50. .lever { height:64px; font-size:20px; background:var(--gold); color:#111; border-radius:14px; }
  51. /* reveal */
  52. #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; }
  53. #reveal.on { display:flex; }
  54. #reveal .scan { position:absolute; inset:0; background:repeating-linear-gradient(0deg, rgba(255,255,255,.04) 0 1px, transparent 1px 3px); pointer-events:none; }
  55. .reel { font-size:34px; font-weight:800; min-height:48px; letter-spacing:.5px; }
  56. .reel.shake { animation:shake .12s 4; }
  57. @keyframes shake { 0%{transform:translate(2px,-1px)} 50%{transform:translate(-2px,1px)} 100%{transform:translate(0)} }
  58. .winner { font-size:40px; font-weight:900; color:var(--gold); margin:10px 0; }
  59. .upset { color:#ff4d6d; font-weight:900; letter-spacing:2px; }
  60. .dealer { font-size:16px; opacity:.9; margin:8px 0 18px; max-width:340px; }
  61. .odds-list { font-size:13px; opacity:.7; }
  62. .reel-frame { border:2px solid var(--gold); border-radius:12px; padding:18px 24px; min-width:260px; }
  63. </style>
  64. </head>
  65. <body>
  66. <div id="app"></div>
  67. <div id="reveal"><div class="scan"></div><div id="reveal-body"></div></div>
  68. <script>
  69. const $ = s => document.querySelector(s);
  70. const handId = location.pathname.startsWith('/h/') ? location.pathname.split('/')[2] : null;
  71. let me = null, state = null, ws = null, revealed = null;
  72. const MORE = 'more_options';
  73. async function api(path, body) {
  74. const r = await fetch(path, { method: body ? 'POST' : 'GET', credentials:'include', headers:{'Content-Type':'application/json'}, body: body ? JSON.stringify(body) : undefined });
  75. if (!r.ok) { const t = await r.text(); toast(t); throw new Error(t); }
  76. return r.json();
  77. }
  78. 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); }
  79. async function session() {
  80. const r = await fetch('/api/auth/get-session', { credentials:'include' });
  81. const t = await r.text(); if (!t) return null;
  82. const j = JSON.parse(t); return j && j.user ? { id:j.user.id, name:j.user.name } : null;
  83. }
  84. // ---------- screens ----------
  85. function render() {
  86. if (!me) return renderJoin();
  87. if (!handId) return renderCreate();
  88. if (!state) return $('#app').innerHTML = '<p class="mute">Loading hand…</p>';
  89. renderHand();
  90. }
  91. function renderJoin() {
  92. document.body.className = '';
  93. $('#app').innerHTML = `
  94. <h1>Plan Maker 2000</h1>
  95. <div class="card">
  96. <p class="mute">Who are you at this table?</p>
  97. <input id="name" placeholder="Your name" autocomplete="off">
  98. <button onclick="joinAnon()">Jump in</button>
  99. <details><summary class="mute">Sign in with phone (OTP)</summary>
  100. <input id="phone" placeholder="+61…" inputmode="tel">
  101. <button class="ghost" onclick="sendOtp()">Send code</button>
  102. <input id="code" placeholder="6-digit code" inputmode="numeric" style="display:none">
  103. <button class="ghost" id="verify" onclick="verifyOtp()" style="display:none">Verify</button>
  104. </details>
  105. </div>`;
  106. }
  107. async function joinAnon() {
  108. const name = $('#name').value.trim() || 'anon';
  109. let s = await session();
  110. if (!s) { await fetch('/api/auth/sign-in/anonymous', { method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body:'{}' }); }
  111. await api('/api/me/name', { name });
  112. me = await session(); boot();
  113. }
  114. async function sendOtp() {
  115. await api('/api/auth/phone-number/send-otp', { phoneNumber: $('#phone').value.trim() });
  116. $('#code').style.display = 'block'; $('#verify').style.display = 'block'; toast('Code sent (check wrangler console in dev)');
  117. }
  118. async function verifyOtp() {
  119. await api('/api/auth/phone-number/verify', { phoneNumber: $('#phone').value.trim(), code: $('#code').value.trim() });
  120. const name = $('#name').value.trim(); if (name) await api('/api/me/name', { name });
  121. me = await session(); boot();
  122. }
  123. function renderCreate() {
  124. document.body.className = '';
  125. $('#app').innerHTML = `
  126. <h1>New hand</h1>
  127. <div class="card">
  128. <input id="q" placeholder="Question (e.g. Friday dinner)">
  129. <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)">
  130. <input id="con" placeholder="Constraints, comma separated (optional)">
  131. <button onclick="createHand()">Open the table</button>
  132. <p class="mute">Screenshot intake comes later; type it for now.</p>
  133. </div>`;
  134. }
  135. async function createHand() {
  136. const options = [...document.querySelectorAll('.o')].map(i=>i.value.trim()).filter(Boolean);
  137. const constraints = $('#con').value.split(',').map(s=>s.trim()).filter(Boolean);
  138. const h = await api('/api/hands', { question: $('#q').value.trim(), options, constraints });
  139. location.href = '/h/' + h.id;
  140. }
  141. function label(id){ return (state.round.options.find(o=>o.id===id)||{}).label || id; }
  142. function mine(){ return state.round.chips[me.id] || {}; }
  143. function spent(){ return Object.values(mine()).reduce((a,b)=>a+b,0); }
  144. 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; }
  145. function renderHand() {
  146. const r = state.round, odds = r.odds, live = new Set(state.live);
  147. const spice = odds ? odds.spice : 0;
  148. document.body.className = state.status==='open' ? (spice>=2 ? 'felt hot' : 'felt') : 'felt';
  149. const meIn = state.members.find(m=>m.id===me.id);
  150. const left = state.chipsPerMember - spent();
  151. const vetoLeft = state.vetoesPerMember - (state.vetoesUsed[me.id]||0);
  152. const t = totals();
  153. const opts = r.options.map(o => {
  154. const dead = !live.has(o.id);
  155. const p = odds && odds.probabilities[o.id] != null ? odds.probabilities[o.id] : null;
  156. const n = mine()[o.id]||0, others = (t[o.id]||0) - n;
  157. return `<div class="opt ${dead?'dead':''} ${o.id===MORE?'more':''}">
  158. <div style="flex:1">
  159. <div class="label">${o.label}</div>
  160. <div class="chips">${'<span class="chip"></span>'.repeat(n)}${'<span class="chip other"></span>'.repeat(Math.max(0,others))}</div>
  161. </div>
  162. ${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>` : ''}
  163. <div class="odds">${p==null?'—':Math.round(p*100)+'%'}</div>
  164. ${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>` : ''}
  165. <div class="bar ${odds && odds.confidence<0.5?'wobble':''}" style="width:${p==null?0:Math.round(p*100)}%"></div>
  166. </div>`;
  167. }).join('');
  168. 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('');
  169. 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';
  170. let footer = '';
  171. if (state.status==='open') footer = meIn && !meIn.locked
  172. ? `<button class="ghost" onclick="act({op:'lock'})">Lock in (${left} left)</button><button class="lever" onclick="act({op:'pull'})">PULL</button>`
  173. : `<button class="lever" onclick="act({op:'pull'})">PULL THE LEVER</button>`;
  174. 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>`;
  175. if (state.status==='accepted') footer = `<button onclick="location.href='/'">New hand</button>`;
  176. $('#app').innerHTML = `
  177. <div class="mute">Round ${r.n} · ${state.status}</div>
  178. <h1>${state.question}</h1>
  179. <div class="members">${members}</div>
  180. ${!meIn ? `<button onclick="act({op:'join'})">Sit down as ${me.name}</button>` : ''}
  181. <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>
  182. ${opts}
  183. <div class="jev"><div class="face"></div><div>${jevLine}</div></div>
  184. ${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>` : ''}
  185. ${state.rounds.length>1 ? `<div class="mute">Earlier: ${state.rounds.slice(0,-1).map(x=>x.pull?label(x.pull.winner):'?').join(' → ')}</div>` : ''}
  186. <div class="footer"><div class="inner">${footer}</div></div>`;
  187. }
  188. async function act(body){ await api(`/api/hands/${handId}/act`, body); }
  189. async function place(o,n){ await act({op:'place', option:o, n}); }
  190. async function veto(o){ if (confirm('Veto '+label(o)+'? Everyone will see.')) await act({op:'veto', option:o}); }
  191. 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)}); }
  192. // ---------- reveal: reels with the near-miss ----------
  193. function playReveal(pull) {
  194. const el = $('#reveal'), body = $('#reveal-body');
  195. el.classList.add('on'); document.body.classList.add('crt');
  196. const seq = pull.order; // ..., runner-up, winner
  197. const pw = pull.odds[pull.winner];
  198. let i = 0;
  199. body.innerHTML = `<div class="mute">The lever has spoken</div><div class="reel-frame"><div class="reel" id="reel">…</div></div>`;
  200. const reel = $('#reel');
  201. const step = () => {
  202. if (i < seq.length) {
  203. reel.textContent = label(seq[i]);
  204. const isRunnerUp = seq[i]===pull.runnerUp && i===seq.length-2;
  205. const delay = i===seq.length-1 ? 0 : isRunnerUp ? 900 : Math.max(120, 420 - i*60); // hang on the runner-up: the near-miss
  206. if (isRunnerUp) reel.classList.add('shake');
  207. i++; setTimeout(step, delay);
  208. } else {
  209. body.innerHTML = `${pw<0.3?'<div class="upset">UPSET</div>':''}<div class="mute">${state.question}</div><div class="winner">${label(pull.winner)}</div>
  210. <div class="mute">${Math.round(pw*100)}% at the pull</div><div class="dealer">${pull.dealer}</div>
  211. <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>
  212. <button class="ghost" style="margin-top:20px;width:auto" onclick="closeReveal()">ok</button>`;
  213. if (navigator.vibrate) navigator.vibrate([30,40,30,40,120]);
  214. }
  215. };
  216. setTimeout(step, 600);
  217. }
  218. function closeReveal(){ $('#reveal').classList.remove('on'); document.body.classList.remove('crt'); render(); }
  219. // ---------- socket ----------
  220. function connect() {
  221. const proto = location.protocol==='https:' ? 'wss' : 'ws';
  222. ws = new WebSocket(`${proto}://${location.host}/api/hands/${handId}/ws`);
  223. ws.onmessage = ev => {
  224. if (ev.data==='pong') return;
  225. const m = JSON.parse(ev.data);
  226. if (m.type==='state') {
  227. const prevPull = state && state.round && state.round.pull ? state.round.pull.at : null;
  228. state = m.state;
  229. const p = state.round.pull;
  230. if (p && p.at !== prevPull && p.at !== revealed) { revealed = p.at; playReveal(p); return; }
  231. render();
  232. }
  233. if (m.type==='odds') { if (state) { state.round.odds = m.odds; render(); } }
  234. if (m.type==='error') toast(m.error.slice(0,120));
  235. };
  236. ws.onclose = () => setTimeout(connect, 1500);
  237. setInterval(()=>{ try{ ws.send('ping'); }catch{} }, 25000);
  238. }
  239. async function boot() {
  240. me = await session();
  241. if (me && handId) {
  242. state = await api(`/api/hands/${handId}`);
  243. if (!state.members.find(m=>m.id===me.id)) await act({op:'join'});
  244. if (!ws) connect();
  245. }
  246. render();
  247. }
  248. boot();
  249. </script>
  250. </body>
  251. </html>