summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 12:24:25 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 12:24:25 +0100
commitc45ff4525c0e45e4b8e762b81fd180c53d206d66 (patch)
treebd0ffca5835d612a884d973e65f17cf954d69239 /web
parentweb: remove .git from repo link (diff)
downloadzagan-bet-c45ff4525c0e45e4b8e762b81fd180c53d206d66.tar.gz
zagan-bet-c45ff4525c0e45e4b8e762b81fd180c53d206d66.zip
web: match the desktop layout and remove play-money copy
The browser now uses the desktop's full-width game split: no extra game header or card frame, a full-height sidebar with a pinned action block, and the same lobby grid, topbar account/balance blocks, and bottom-left notices. Slots uses the desktop symbol colours and gold win border; roulette gets the flat felt, column bets under the dozens, the wheel, circular chips, and a LAST line; blackjack gets a centred dealer/player table and divider; crash gets the chart panel, phase label and empty ready state. Also drops every play-money/real-money disclaimer from the page copy.
Diffstat (limited to '')
-rw-r--r--web/play/app.js2
-rw-r--r--web/play/index.html42
-rw-r--r--web/play/src/blackjack.ts58
-rw-r--r--web/play/src/crash.ts56
-rw-r--r--web/play/src/history.ts15
-rw-r--r--web/play/src/main.ts97
-rw-r--r--web/play/src/roulette.ts339
-rw-r--r--web/play/src/slots.ts32
-rw-r--r--web/play/style.css188
-rw-r--r--web/play/test/smoke.mjs6
10 files changed, 501 insertions, 334 deletions
diff --git a/web/play/app.js b/web/play/app.js
index 89db817..e76f5a6 100644
--- a/web/play/app.js
+++ b/web/play/app.js
@@ -1 +1 @@
"use strict";(()=>{function g(e){let n=document.getElementById(e);if(!n)throw new Error(`missing #${e}`);return n}function o(e,n,t){let s=document.createElement(e);return n&&(s.className=n),t!==void 0&&(s.textContent=t),s}function H(e){e.replaceChildren()}function Ke(e){let n=e<0,t=String(Math.abs(Math.trunc(e))),s="";for(let a=0;a<t.length;a+=1)a>0&&(t.length-a)%3===0&&(s+=" "),s+=t[a];return n?`-${s}`:s}function L(e){return e==null||Number.isNaN(e)?"— Kr":`${Ke(e)} Kr`}function be(e){return`${e<0?"-":"+"}${L(Math.abs(e))}`}function ne(e,n="info"){let t=document.getElementById("toasts");if(!t||!e)return;let s=o("div",`toast ${n}`,e);for(t.append(s),window.setTimeout(()=>{s.style.opacity="0",s.style.transition="opacity 220ms ease",window.setTimeout(()=>s.remove(),240)},4200);t.children.length>4;)t.firstElementChild?.remove()}function ge(e){let n=e.toLowerCase();return e.startsWith("ERROR")||n.includes("failed")||n.includes("lost")||n.includes("invalid")||n.includes("forfeit")?"bad":e.startsWith("Logged in")||e.startsWith("Account created")||n.includes("won")||n.includes("cashed")?"good":"info"}async function ye(e){let n=new TextEncoder().encode(e),t=await crypto.subtle.digest("SHA-256",n);return[...new Uint8Array(t)].map(s=>s.toString(16).padStart(2,"0")).join("")}function z(e,n=10){return e.length<=n*2?e:`${e.slice(0,n)}…${e.slice(-n)}`}function ke(e){let n=Date.parse(e.includes("T")?e:e.replace(" ","T")+"Z");return Number.isNaN(n)?e:new Date(n).toLocaleString()}function Le(e){if(e==="?"||!e)return{rank:"?",suit:"",red:!1};let n=e.slice(-1);return{rank:e.slice(0,-1),suit:n==="C"?"♣":n==="D"?"♦":n==="H"?"♥":n==="S"?"♠":n,red:n==="D"||n==="H"}}var Y=class{constructor(n,t){this.url=n;this.handlers=t;this.socket=null;this.stopped=!1;this.replaced=!1;this.attempts=0}connect(){if(this.stopped)return;this.clearTimer();let n;try{n=new WebSocket(this.url)}catch{this.scheduleReconnect()||this.handlers.onClosed(!1);return}this.socket=n,n.addEventListener("open",()=>{this.attempts=0,this.handlers.onOpen()}),n.addEventListener("message",t=>{let s;try{s=JSON.parse(String(t.data))}catch{return}switch(s.type){case"hello":this.handlers.onHello(s);break;case"view":this.handlers.onView(s.view);break;case"notice":this.handlers.onNotice(String(s.text??""));break;case"error":this.handlers.onError(String(s.text??""));break;case"replaced":this.replaced=!0,this.handlers.onReplaced();break;case"pong":this.handlers.onPong();break}}),n.addEventListener("close",()=>{if(this.socket===n&&(this.socket=null),this.replaced||this.stopped){this.handlers.onClosed(!1);return}let s=this.scheduleReconnect();this.handlers.onClosed(s)}),n.addEventListener("error",()=>{})}send(n){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"command",command:n}))}ping(){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"ping"}))}close(){this.stopped=!0,this.clearTimer(),this.socket?.close()}scheduleReconnect(){if(this.stopped||this.replaced||this.attempts>=6)return!1;this.attempts+=1;let n=Math.min(250*2**(this.attempts-1),4e3);return this.timer=window.setTimeout(()=>this.connect(),n),!0}clearTimer(){this.timer!==void 0&&(window.clearTimeout(this.timer),this.timer=void 0)}};var xe=0;function oe(){return Date.now()-xe<400?!1:(xe=Date.now(),!0)}function Ze(e){if(e==="?"){let s=o("div","card back");return s.append(o("span","rank","?"),o("span","suit","")),s}let n=Le(e),t=o("div","card");return n.red&&t.classList.add("red"),t.append(o("span","rank",n.rank),o("span","suit",n.suit)),t}function we(e,n,t){let s=o("div","hand");s.append(o("div","label",e));let a=o("div","cards");for(let d of n)a.append(Ze(d));let r=o("div","total");return r.append(document.createTextNode("Total "),o("strong",void 0,String(t))),s.append(a,r),s}function Je(e,n){let t=String(n?.result??""),s=Number(n?.winnings??0);switch(t){case"blackjack":return`Blackjack — won ${L(s)}`;case"win":return`You win — ${L(s)}`;case"push":return`Push — ${L(s)} returned`;case"bust":return"Bust — the bet is lost";case"lose":return"Dealer wins — the bet is lost";case"forfeit":return"Hand forfeited — the bet is lost";case"playing":return e.status==="player"?"Your move.":"Dealing…";default:return"Ready to deal."}}function ve(e,n,t,s){H(e),H(n);let a=t.hand,r=a?.phase==="player"||t.status==="player",d=a?.phase==="done"||t.status==="done",i=!a||d;if(e.append(o("h2",void 0,"Blackjack")),e.append(o("p","note","Six decks. Dealer stands on all 17. Blackjack pays 3:2.")),a)e.append(we("Dealer",a.dealer??[],Number(a.dealer_total??0))),e.append(we("You",a.player??[],Number(a.player_total??0)));else{let f=o("div","outcome");f.textContent="Press DEAL to start a hand.",e.append(f)}if(d||a&&!r){let f=o("div","outcome");f.textContent=Je(t,a),(String(a?.result)==="win"||String(a?.result)==="blackjack"||String(a?.result)==="push")&&f.classList.add("win-text"),e.append(f)}t.error&&e.append(o("p","error-text",String(t.error))),n.append(o("div","label","Blackjack"));let m=Number(a?.bet??t.bet??0),p=t.balance??t.hand?.balance;if(n.append(o("p","note","Hit, stand or double. Forfeiting loses the reserved bet.")),i){let f=o("div","row"),w=o("button","btn small","−10"),k=o("strong","mono",L(m)),v=o("button","btn small","+10");w.addEventListener("click",()=>s({AdjustBet:-10})),v.addEventListener("click",()=>s({AdjustBet:10})),f.append(w,k,v),n.append(o("div","label","Bet"),f)}else{let f=o("div","stat");f.append(o("span","muted","Bet"),o("strong",void 0,L(m))),n.append(f)}if(p!==void 0){let f=o("div","stat");f.append(o("span","muted","Balance"),o("strong",void 0,L(Number(p)))),n.append(f)}let u=o("div","actions");if(r){let f=o("div","row wrap");for(let k of a?.actions??[]){let v=k==="hit"?"HIT":k==="stand"?"STAND":"DOUBLE",E=k==="stand"?o("button","btn primary",v):o("button","btn",v);E.addEventListener("click",()=>{oe()&&s(k==="hit"?"Hit":k==="stand"?"Stand":"Double")}),f.append(E)}u.append(f);let w=o("button","btn danger wide","Forfeit hand");w.addEventListener("click",()=>{oe()&&window.confirm("Forfeit this hand? The reserved bet is lost.")&&s("Forfeit")}),u.append(w)}else if(i){let f=o("button","btn primary wide big","DEAL");f.addEventListener("click",()=>{oe()&&s("Deal")}),u.append(f)}let y=o("button","btn ghost wide",r?"Leave (forfeits)":"Leave table");y.addEventListener("click",()=>{r&&!window.confirm("Leaving forfeits this hand. Continue?")||s("Leave")}),u.append(y),n.append(u)}var se=Math.LN2/5,B=null,I=null,Ce=null,P=null,$,Te=0,X="",Ee=0;function qe(e,n,t,s){H(e),H(n),P=t,Ce=s,e.append(o("h2",void 0,"Crash")),e.append(o("p","note","The multiplier rises from 1.00×. Cash out before it crashes."));let a=o("div","crash-wrap"),r=o("div","crash-number","1.00×"),d=o("div","crash-meta"),i=o("canvas","crash-canvas"),m=o("div","verify","Waiting for a round commitment.");a.append(r,d,i,m),e.append(a),n.append(o("div","label","Crash")),n.append(o("p","note","The server commits to a seed before the round. The seed is revealed when it ends."));let p=o("div","row"),u=o("button","btn small","−10"),y=o("strong","mono","—"),f=o("button","btn small","+10");u.addEventListener("click",()=>P?.({AdjustBet:-10})),f.addEventListener("click",()=>P?.({AdjustBet:10})),p.append(u,y,f),n.append(o("div","label","Bet"),p);let w=o("strong","mono","—"),k=o("strong","mono","—");for(let[M,N]of[["Potential",w],["Balance",k]]){let b=o("div","stat");b.append(o("span","muted",M),N),n.append(b)}let v=o("p","note","Ready."),E=o("p","note",""),S=o("p","error-text");n.append(v,E,S);let l=o("div","actions"),c=o("button","btn primary wide big","START"),h=o("button","btn ghost wide","Leave table");return c.addEventListener("click",()=>{Date.now()-Ee<500||(Ee=Date.now(),I?.status==="running"?P?.("CashOut"):P?.("Start"))}),h.addEventListener("click",()=>{I?.status==="running"&&!window.confirm("Forfeit this round? The bet is lost.")||P?.("Leave")}),l.append(c,h),n.append(l),{board:e,side:n,number:r,meta:d,verify:m,canvas:i,bet:y,potential:w,balance:k,status:v,lastCrash:E,error:S,primary:c,leave:h,minus:u,plus:f}}function Se(){$!==void 0&&(cancelAnimationFrame($),$=void 0),I=null}function Ne(e,n,t,s,a){(!B||B.board!==e||!B.canvas.isConnected)&&(B=qe(e,n,s,a),X="");let r=B;P=s,Ce=a,I=t,t.status!==X&&((t.status==="starting"||t.status==="running")&&X!=="running"&&(r.verify.className="verify",r.verify.textContent="Round committed; seed hidden until the round ends."),X=String(t.status));let d=t.status==="running",i=t.status==="starting",m=d||i;r.bet.textContent=L(Number(t.bet??0)),r.potential.textContent=L(Number(t.potential??t.bet??0)),r.balance.textContent=t.balance===null||t.balance===void 0?"—":L(Number(t.balance)),r.error.textContent=t.error?String(t.error):"",r.minus.disabled=m,r.plus.disabled=m,r.primary.textContent=d?"CASH OUT":i?"STARTING…":"START",r.primary.disabled=i,r.primary.className="btn primary wide big",r.leave.textContent=d?"Forfeit round":"Leave table",d?(r.status.textContent="Rising — cash out before the crash.",r.status.className="note win-text"):i?(r.status.textContent="Starting the round…",r.status.className="note"):t.status==="cashed"?(r.status.textContent=`Cashed out at ${Number(t.multiplier??0).toFixed(2)}× for ${L(Number(t.payout??0))}.`,r.status.className="note win-text"):t.status==="crashed"?(r.status.textContent=`Crashed at ${Number(t.crash_point??t.multiplier??0).toFixed(2)}×. The bet is lost.`,r.status.className="note error-text"):t.status==="forfeit"?(r.status.textContent="Round forfeited.",r.status.className="note error-text"):(r.status.textContent="Ready. Start a round when you want.",r.status.className="note"),t.crash_point!==null&&t.crash_point!==void 0&&t.status==="crashed"&&(r.lastCrash.textContent=`Last crash: ${Number(t.crash_point).toFixed(2)}×`);let p=t.commit?String(t.commit):"";if(r.meta.textContent=p?`commit ${z(p,12)}`:"no commitment yet",t.seed&&p){let u=++Te,y=String(t.seed),f=Number(t.crash_point??0);tt(y,p,f).then(([w,k])=>{if(u!==Te||!B)return;let v=w&&k;B.verify.className=`verify ${v?"ok":"bad"}`,B.verify.textContent=v?`Seed ${z(y,12)} · sha256 matches commit · crash point recomputed`:`VERIFICATION FAILED · seed ${z(y,12)} · commit ${z(p,12)}`})}else!m&&!p&&(r.verify.className="verify",r.verify.textContent="Waiting for a round commitment.");if($!==void 0&&(cancelAnimationFrame($),$=void 0),d){let u=()=>{if(!B||I?.status!=="running"){$=void 0;return}Me(B,I,a),$=requestAnimationFrame(u)};$=requestAnimationFrame(u)}else Me(r,t,a)}function Qe(e,n){if(e.status==="running"){let t=Math.max(0,(n.now()-Number(e.started_at??0))/1e3),s=Math.min(1e3,Math.exp(se*t));return Math.max(s,Number(e.multiplier??1))}return e.status==="cashed"?Number(e.multiplier??1):e.status==="crashed"?Number(e.crash_point??e.multiplier??1):1}function Me(e,n,t){let s=Qe(n,t);e.number.textContent=`${s.toFixed(2)}×`,e.number.className=`crash-number ${n.status==="running"?"running":n.status==="cashed"?"cashed":n.status==="crashed"?"crashed":""}`,e.potential.textContent=L(Math.floor(Number(n.bet??0)*s)),et(e.canvas,n,s,t)}function et(e,n,t,s){let a=e.clientWidth||600,r=e.clientHeight||360,d=window.devicePixelRatio||1;(e.width!==Math.round(a*d)||e.height!==Math.round(r*d))&&(e.width=Math.round(a*d),e.height=Math.round(r*d));let i=e.getContext("2d");if(!i)return;i.setTransform(d,0,0,d,0,0),i.clearRect(0,0,a,r);let m=54,p=16,u=18,y=34,f=Math.max(1,a-m-p),w=Math.max(1,r-u-y),k=n.status==="running"?Math.max(.25,(s.now()-Number(n.started_at??0))/1e3):t>1?Math.log(t)/se:1,v=Math.max(5,k*1.08),E=Math.max(2,Math.ceil(t*1.15*2)/2),S=b=>m+b/v*f,l=b=>u+w-(b-1)/(E-1)*w;i.strokeStyle="#242e38",i.fillStyle="#8a98a6",i.font="11px ui-monospace, monospace",i.lineWidth=1;for(let b=0;b<=4;b+=1){let T=1+(E-1)*b/4,C=l(T);i.beginPath(),i.moveTo(m,C),i.lineTo(a-p,C),i.stroke(),i.fillText(`${T.toFixed(1)}×`,6,C+4)}for(let b=0;b<=4;b+=1){let T=v*b/4,C=S(T);i.beginPath(),i.moveTo(C,u),i.lineTo(C,u+w),i.strokeStyle="#1b232c",i.stroke(),i.fillStyle="#5b6773",i.fillText(`${T.toFixed(0)}s`,C-10,r-12)}let c=n.status==="crashed"?"#e05a5a":n.status==="cashed"?"#4ec27b":"#e0b64a";i.beginPath();let h=180;for(let b=0;b<=h;b+=1){let T=k*b/h,C=Math.min(E,Math.exp(se*T)),fe=S(T),he=l(C);b===0?i.moveTo(fe,he):i.lineTo(fe,he)}i.strokeStyle=c,i.lineWidth=2.4,i.stroke();let M=S(k),N=l(Math.min(E,t));i.beginPath(),i.arc(M,N,4.5,0,Math.PI*2),i.fillStyle=c,i.fill(),i.fillStyle=c,i.font="600 12px ui-monospace, monospace",i.fillText(`${t.toFixed(2)}×`,Math.min(M+8,a-70),Math.max(u+12,N-10))}async function tt(e,n,t){let s=new Uint8Array(e.length/2);for(let u=0;u<s.length;u+=1)s[u]=Number.parseInt(e.slice(u*2,u*2+2),16);let a=await crypto.subtle.digest("SHA-256",s),d=[...new Uint8Array(a)].map(u=>u.toString(16).padStart(2,"0")).join("")===n.toLowerCase(),i=0;for(let u=0;u<8;u+=1)i=i*256+s[u];i/=2**64;let m=Math.max(1,Math.min(1e3,(1-.01)/(1-i))),p=Math.abs(m-t)<1e-6;return[d,p]}function nt(e){let n=e.details??{};switch(e.game){case"slots":return String(n.win_type??"spin");case"roulette":return`${n.number??"?"} ${n.color??""}`.trim();case"blackjack":return String(n.result??"");case"crash":{let t=String(n.result??""),s=n.crash_point;return s==null?t:`${t} @ ${Number(s).toFixed(2)}×`}default:return""}}function He(e,n){H(e);let t=n.records??[];if(t.length===0){let m=o("div","board");m.append(o("h2",void 0,"No plays yet"),o("p","muted","Your settled plays will appear here.")),e.append(m);return}let s=o("div","table-wrap"),a=o("table"),r=o("thead"),d=o("tr");for(let m of["Time","Game","Bet","Result","Net","Balance"])d.append(o("th",void 0,m));r.append(d);let i=o("tbody");for(let m of t){let p=o("tr");p.append(o("td","mono",ke(String(m.at??"")))),p.append(o("td",void 0,String(m.game??""))),p.append(o("td","num",L(Number(m.bet??0)))),p.append(o("td","muted",nt(m)));let u=Number(m.winnings??0)-Number(m.bet??0),y=o("td",`num ${u>0?"pos":u<0?"neg":""}`,u===0?"0 Kr":be(u));p.append(y),p.append(o("td","num",L(Number(m.balance_after??0)))),i.append(p)}a.append(r,i),s.append(a),e.append(s)}var re=null,Re=0,ot="#b0313a",st="#151b21",Be="#106b45",rt="#0d5c39",at="#09472c",it="#1f7d52",lt="#ecf7f0",ct="#e0b64a";function dt(e){return e===0?"green":[1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36].includes(e)?"red":"black"}function ut(e,n){let s=Math.max(38,e*.055),a=Math.max(34,e*.05),r=Math.max(30,n*.07),d=6,i=e-28-s-a,m=i/12,p=(n-28-r*2-d*2)/3,u=14+s,y=14,f=y+p*3+d,w=f+r+d,k=[];for(let l=0;l<12;l+=1)for(let c=0;c<3;c+=1){let h=l*3+(3-c);k.push({x:u+l*m,y:y+c*p,w:m,h:p,target:{bet:"straight",number:h},label:String(h),kind:"number",number:h})}k.push({x:14,y,w:s,h:p*3,target:{bet:"straight",number:0},label:"0",kind:"zero",number:0});for(let l=0;l<3;l+=1){let c=2-l;k.push({x:u+i,y:y+l*p,w:a,h:p,target:{bet:"column",index:c},label:"2:1",kind:"column"})}for(let l=0;l<3;l+=1)k.push({x:u+i/3*l,y:f,w:i/3,h:r,target:{bet:"dozen",index:l},label:["1st 12","2nd 12","3rd 12"][l],kind:"dozen"});[{kind:"low",label:"1–18"},{kind:"even",label:"EVEN"},{kind:"red",label:"RED"},{kind:"black",label:"BLACK"},{kind:"odd",label:"ODD"},{kind:"high",label:"19–36"}].forEach((l,c)=>{k.push({x:u+i/6*c,y:w,w:i/6,h:r,target:{bet:"outside",kind:l.kind},label:l.label,kind:"outside"})});let E=[],S=l=>{let c=k.find(h=>h.kind==="number"&&h.number===l);return c?{x:c.x+c.w/2,y:c.y+c.h/2}:null};for(let l=0;l<11;l+=1)for(let c=0;c<2;c+=1){let h=l*3+(3-c);E.push({x:u+(l+1)*m,y:y+(c+1)*p,radius:12,rank:0,target:{bet:"corner",numbers:[h,h-1,h+3,h+2]}})}for(let l=0;l<12;l+=1)for(let c=0;c<2;c+=1){let h=l*3+(3-c),M=S(h);M&&E.push({x:M.x,y:y+(c+1)*p,radius:10,rank:1,target:{bet:"split",a:h,b:h-1}})}for(let l=0;l<11;l+=1)for(let c=0;c<3;c+=1){let h=l*3+(3-c);E.push({x:u+(l+1)*m,y:y+c*p+p/2,radius:10,rank:1,target:{bet:"split",a:h,b:h+3}})}for(let l=0;l<3;l+=1){let c=3-l;E.push({x:u,y:y+l*p+p/2,radius:10,rank:1,target:{bet:"split",a:0,b:c}})}return{width:e,height:n,cellW:m,cellH:p,zeroX:14,zeroW:s,gridX:u,gridY:y,gridW:i,columnX:u+i,dozensY:f,outsideY:w,outsideH:r,zones:k,hotspots:E}}function $e(e,n,t,s,a,r){let d=Math.min(r,s/2,a/2);e.beginPath(),e.moveTo(n+d,t),e.arcTo(n+s,t,n+s,t+a,d),e.arcTo(n+s,t+a,n,t+a,d),e.arcTo(n,t+a,n,t,d),e.arcTo(n,t,n+s,t,d),e.closePath()}function Ae(e,n){let t=e.clientWidth||800,s=e.clientHeight||540,a=window.devicePixelRatio||1;(e.width!==Math.round(t*a)||e.height!==Math.round(s*a))&&(e.width=Math.round(t*a),e.height=Math.round(s*a));let r=e.getContext("2d"),d=ut(t,s);if(!r)return d;r.setTransform(a,0,0,a,0,0),r.clearRect(0,0,t,s),r.fillStyle=at,$e(r,0,0,t,s,10),r.fill();for(let i of d.zones){let m=rt;i.kind==="number"?m=i.number===0?Be:dt(i.number)==="red"?ot:st:i.kind==="zero"&&(m=Be),r.fillStyle=m,r.strokeStyle=it,r.lineWidth=1,$e(r,i.x+1,i.y+1,i.w-2,i.h-2,3),r.fill(),r.stroke(),r.fillStyle=lt,r.textAlign="center",r.textBaseline="middle",i.kind==="number"||i.kind==="zero"?r.font=`700 ${Math.min(24,d.cellH*.3)}px ui-monospace, monospace`:r.font=`600 ${Math.min(12,d.outsideH*.4)}px ui-monospace, monospace`,r.fillText(i.label,i.x+i.w/2,i.y+i.h/2)}return mt(r,d,n),d}function We(e){return JSON.stringify(e)}function pt(e,n){let t=We(n),s=e.zones.find(a=>We(a.target)===t);if(s)return{x:s.x+s.w/2,y:s.y+s.h/2};if(n?.bet==="split"||n?.bet==="corner"){let a=n.bet==="split"?[n.a,n.b]:n.numbers,r=e.zones.filter(d=>(d.kind==="number"||d.kind==="zero")&&a.includes(d.number));if(r.length>0)return{x:r.reduce((d,i)=>d+i.x+i.w/2,0)/r.length,y:r.reduce((d,i)=>d+i.y+i.h/2,0)/r.length}}return null}function mt(e,n,t){let s=t.bets?.chips??[];for(let a of s){let r=pt(n,a.target);if(!r)continue;let d=Math.max(12,Math.min(18,n.cellW*.26));e.beginPath(),e.arc(r.x,r.y,d,0,Math.PI*2),e.fillStyle=ct,e.fill(),e.strokeStyle="#7a5a12",e.lineWidth=2,e.stroke(),e.fillStyle="#221a05",e.textAlign="center",e.textBaseline="middle",e.font=`700 ${d>15?11:10}px ui-monospace, monospace`,e.fillText(String(a.amount),r.x,r.y+.5)}}function De(e,n,t,s){H(e),H(n);let a=t.phase==="spinning";e.append(o("h2",void 0,"Roulette")),e.append(o("p","note","European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back."));let r=o("div","roulette-wrap"),d=o("canvas","roulette-canvas");if(r.append(d),t.phase==="result"&&t.number!==null&&t.number!==void 0){let l=o("div",`wheel-result ${t.color??""}`);l.append(o("div","label","Result")),l.append(o("div","num",String(t.number)));let c=Number(t.winnings??0);l.append(o("div","note",c>0?`Won ${L(c)}`:"No win")),r.append(l)}e.append(r);let m=Ae(d,t),p=(l,c)=>{let h=d.getBoundingClientRect(),M=l-h.left,N=c-h.top,b=null;for(let T of m.hotspots){let C=Math.hypot(M-T.x,N-T.y);C>T.radius||(!b||T.rank<b.rank||T.rank===b.rank&&C<Math.hypot(M-b.x,N-b.y))&&(b=T)}if(b)return{x:b.x-b.radius,y:b.y-b.radius,w:b.radius*2,h:b.radius*2,target:b.target,label:"",kind:"number"};for(let T=m.zones.length-1;T>=0;T-=1){let C=m.zones[T];if(M>=C.x&&M<=C.x+C.w&&N>=C.y&&N<=C.y+C.h)return C}return null};d.addEventListener("click",l=>{if(a)return;let c=p(l.clientX,l.clientY);c&&s({PlaceBetAt:c.target})}),d.addEventListener("contextmenu",l=>{if(l.preventDefault(),a)return;let c=p(l.clientX,l.clientY);c&&s({RemoveBetAt:c.target})}),d.addEventListener("mousemove",l=>{let c=p(l.clientX,l.clientY);d.style.cursor=c&&!a?"pointer":"default"}),re?.disconnect(),re=new ResizeObserver(()=>{m=Ae(d,t)}),re.observe(d),n.append(o("div","label","Roulette")),n.append(o("p","note","Chips sit on the felt. The total is reserved when you spin.")),n.append(o("div","label","Chip"));let u=o("div","chip-row");for(let l of[10,25,50,100,500]){let c=o("button",`chip-btn ${Number(t.chip)===l?"active":""}`,String(l));c.disabled=a,c.addEventListener("click",()=>s({SetChip:l})),u.append(c)}n.append(u);let y=(t.bets?.chips??[]).reduce((l,c)=>l+Number(c.amount??0),0),f=o("div","stat");if(f.append(o("span","muted","On the felt"),o("strong","mono",L(y))),n.append(f),t.balance!==null&&t.balance!==void 0){let l=o("div","stat");l.append(o("span","muted","Balance"),o("strong","mono",L(Number(t.balance)))),n.append(l)}t.error&&n.append(o("p","error-text",String(t.error)));let w=o("p","note");if(a)w.textContent="Spinning…";else if(t.phase==="result"){let l=Number(t.winnings??0);w.textContent=`${t.number} ${t.color} · ${l>0?`won ${L(l)}`:"no win"}`,w.className=l>0?"note win-text":"note"}else w.textContent=y>0?"Ready to spin.":"Place at least one chip.";n.append(w);let k=o("div","actions"),v=o("button","btn primary wide big",a?"SPINNING…":"SPIN WHEEL");v.disabled=a||y===0,v.addEventListener("click",()=>{Date.now()-Re<600||(Re=Date.now(),s("Spin"))});let E=o("button","btn wide","Clear bets");E.disabled=a||y===0,E.addEventListener("click",()=>s("ClearBets"));let S=o("button","btn ghost wide","Leave table");S.disabled=a,S.addEventListener("click",()=>{a||s("Leave")}),k.append(v,E,S),n.append(k)}var ae=5,ie=3,F="CLOGDS",ft=["top","middle","bottom","V","inverted V"],V=0,Pe=0,A,U=null,G=null;function ht(){V=Date.now()+900}function Ie(){A!==void 0&&(window.clearInterval(A),A=void 0),U=null,G=null,V=0}function bt(e){return e.phase==="spinning"||Date.now()<V}function le(e,n,t,s){U=t,G=e,t.error&&(V=0),A!==void 0&&(window.clearInterval(A),A=void 0);let a=bt(t);H(e),H(n);let r=o("h2",void 0,"Slots"),d=o("p","note","Match three or more symbols from the left. Five paylines.");e.append(r,d);let i=o("div","reels"),m=[],p=Array.isArray(t.reels)?t.reels.join(""):String(t.reels??"");for(let c=0;c<ae;c+=1){let h=o("div","reel");for(let M=0;M<ie;M+=1){let N=c*ie+M,b=a?F[(N*3+Math.floor(Date.now()/70))%F.length]:p.length===ae*ie?p[N]:p.length===ae?p[c]:F[(N*5+c)%F.length],T=o("div","tile",b);T.classList.add(`sym-${b}`),a&&T.classList.add("spinning"),!a&&(t.win_cells??[]).includes(N)&&T.classList.add("win"),m.push(T),h.append(T)}i.append(h)}e.append(i);let u=o("div","win-lines");if(!a&&t.phase==="result"){let c=Number(t.winnings??0);if(c>0){let h=(t.win_lines??[]).map(M=>`${M} (${ft[M-1]??"line"})`).join(" · ");u.textContent=`${t.win_type==="mega"?"MEGA WIN":t.win_type==="big"?"BIG WIN":"WIN"} ${L(c)}${h?` · ${h}`:""}`,u.classList.add("win-text")}else u.textContent="No win — spin again."}else a?u.textContent="Spinning…":u.textContent="Pick a bet and spin.";if(e.append(u),a){let c=0;A=window.setInterval(()=>{c+=1;for(let h=0;h<m.length;h+=1){let M=F[(h*3+c)%F.length];m[h].textContent=M,m[h].className=`tile spinning sym-${M}`}Date.now()>=V&&U&&G&&(window.clearInterval(A),A=void 0,le(G,n,U,s))},70)}n.append(o("div","label","Slots")),n.append(o("p","note","5 reels × 3 rows, 5 paylines."));let y=o("div","row"),f=o("button","btn small","−10"),w=o("strong","mono",L(Number(t.bet??0))),k=o("button","btn small","+10");f.disabled=a,k.disabled=a,f.addEventListener("click",()=>s({AdjustBet:-10})),k.addEventListener("click",()=>s({AdjustBet:10})),y.append(f,w,k),n.append(o("div","label","Bet"),y),t.error&&n.append(o("p","error-text",String(t.error)));let v=o("p","note");if(a)v.textContent="Spinning…";else if(t.phase==="result"){let c=Number(t.winnings??0);v.textContent=c>0?`Won ${L(c)}`:"No win this spin.",c>0&&v.classList.add("win-text")}else v.textContent="Ready when you are.";n.append(v);let E=o("div","actions"),S=o("button","btn primary wide big",a?"SPINNING…":"SPIN");S.disabled=a,S.addEventListener("click",()=>{Date.now()-Pe<900||(Pe=Date.now(),ht(),s("Spin"))});let l=o("button","btn ghost wide","Leave machine");l.disabled=a,l.addEventListener("click",()=>s("Leave")),E.append(S,l),n.append(E)}var gt=g("auth-screen"),yt=g("lobby-screen"),kt=g("game-screen"),Lt=g("history-screen"),Fe=g("session-bar"),xt=g("account-name"),wt=g("account-balance"),ce=g("game-grid"),K=g("game-board"),Z=g("game-side"),vt=g("game-title"),Ve=g("game-badge"),Tt=g("lobby-account"),Et=g("history-body"),Mt=g("auth-form"),_e=g("auth-user"),ee=g("auth-pass"),O=g("auth-error"),D=g("auth-submit"),je=g("tab-login"),Ye=g("tab-register"),J=g("reconnect-overlay"),Oe=g("reconnect-message"),ze=g("replaced-overlay"),x=null,W=null,Q=null,j="login",de=null,Xe=0,ue=0,pe=new Map([["slots",10],["roulette",10],["blackjack",10],["crash",10]]),Ue={slots:"Slots",roulette:"Roulette",blackjack:"Blackjack",crash:"Crash"},Ct={slots:"Five reels, five paylines. Match symbols from the left.",roulette:"European single-zero wheel with inside and outside bets.",blackjack:"Six-deck shoe. Blackjack pays 3:2.",crash:"A rising multiplier. Cash out before it crashes."},St=location.pathname.includes("/play/")?location.pathname.slice(0,location.pathname.indexOf("/play/")):"",Nt=`${location.protocol==="https:"?"wss":"ws"}://${location.host}${St}/ws`,q=new URLSearchParams(location.search).get("ws"),Ht=location.hostname==="127.0.0.1"||location.hostname==="localhost"||location.hostname==="::1",Rt=q&&Ht&&(q.startsWith("ws://")||q.startsWith("wss://"))?q:Nt,te=new Y(Rt,{onHello(e){Xe=e.time-Date.now(),W=e.user??null,typeof e.balance=="number"&&(Q=e.balance),Ve.textContent=`v${e.version}`},onView(e){x=e,e.screen!=="slots"&&Ie(),e.screen!=="crash"&&Se(),typeof e.balance=="number"&&(Q=e.balance),e.screen==="lobby"&&"user"in e&&(W=e.user??null,W?ee.value="":Q=null),ue=0,J.hidden=!0,Ge(),W&&x.balance===null&&de!==W&&(de=W,te.send("Info")),W||(de=null)},onNotice(e){e.startsWith("Connected to:")||e.startsWith("Fingerprint:")||e.startsWith("User:")||e.startsWith("Account:")||e.startsWith("Balance:")||(ne(e,ge(e)),e.startsWith("Account created")&&(me("login"),ee.value="",_e.focus()),D.disabled=!1,D.textContent=j==="login"?"Log in":"Create account")},onError(e){ne(e,"bad"),D.disabled=!1,D.textContent=j==="login"?"Log in":"Create account"},onReplaced(){ze.hidden=!1},onPong(){},onOpen(){J.hidden=!0},onClosed(e){if(ze.hidden!==!1){if(!e){Oe.textContent="The connection could not be restored. Reload the page to try again.",J.hidden=!1;return}ue+=1,Oe.textContent=ue>1?"Still trying to reach the game server…":"Trying to reach the game server…",J.hidden=!1}}});function R(e){te.send(e)}function Bt(){return Date.now()+Xe}function me(e){j=e,je.classList.toggle("active",e==="login"),Ye.classList.toggle("active",e==="register"),D.textContent=e==="login"?"Log in":"Create account",ee.autocomplete=e==="login"?"current-password":"new-password",O.textContent=""}function _(e){gt.hidden=e!=="auth",yt.hidden=e!=="lobby",kt.hidden=e!=="game",Lt.hidden=e!=="history"}function $t(e){wt.textContent=L(e??null)}function Ge(){if(!x)return;let e=W;if(e?(Fe.hidden=!1,xt.textContent=String(e)):Fe.hidden=!0,$t(Q),!e){_("auth"),O.textContent=x.login_error?String(x.login_error):"",x.screen==="lobby"&&x.connected===!1&&(O.textContent="The game server is not reachable yet. Retrying…");return}switch(x.screen){case"lobby":_("lobby"),Wt(x);break;case"history":_("history"),He(Et,x);break;case"slots":case"roulette":case"blackjack":case"crash":_("game"),vt.textContent=Ue[x.screen]??x.screen,Ve.textContent=At(x),x.screen==="slots"?le(K,Z,x,R):x.screen==="roulette"?De(K,Z,x,R):x.screen==="blackjack"?ve(K,Z,x,R):Ne(K,Z,x,R,{now:Bt});break;default:_("auth")}}function At(e){return e.screen==="crash"||e.screen==="blackjack"?String(e.status??""):e.screen==="slots"||e.screen==="roulette"?String(e.phase??""):""}function Wt(e){Tt.textContent=e.account?`Account ${e.account}`:"",ce.replaceChildren();let n=e.games??[];for(let[t,s]of n){let a=o("article",`game-card ${t}`);a.append(o("h3",void 0,s||Ue[t]||t)),a.append(o("p",void 0,Ct[t]??"Play a round."));let r=o("div","row wrap"),d=o("label","field");d.style.flex="1",d.append(o("span","label","Bet"));let i=o("input","input bet-input");i.type="number",i.min="1",i.step="10",i.value=String(pe.get(t)??10),i.addEventListener("change",()=>{let p=Math.max(1,Math.floor(Number(i.value)||1));i.value=String(p),pe.set(t,p)}),d.append(i);let m=o("button","btn primary",t==="roulette"?"Open table":"Play");m.addEventListener("click",()=>{let p=Math.max(1,Math.floor(Number(i.value)||1));pe.set(t,p),R({Play:{game:t,bet:p}})}),r.append(d,m),a.append(r),ce.append(a)}n.length===0&&ce.append(o("p","muted","The server did not advertise any games."))}je.addEventListener("click",()=>me("login"));Ye.addEventListener("click",()=>me("register"));Mt.addEventListener("submit",async e=>{e.preventDefault();let n=_e.value.trim(),t=ee.value;if(!n||!t){O.textContent="Enter a username and a password.";return}D.disabled=!0,D.textContent="Working…",O.textContent="";try{let s=await ye(t);R(j==="login"?{Login:{user:n,credential:s}}:{Register:{user:n,credential:s}})}catch{O.textContent="This browser cannot hash the password (needs a secure context).",D.disabled=!1,D.textContent=j==="login"?"Log in":"Create account"}});g("logout").addEventListener("click",()=>R("Logout"));g("show-history").addEventListener("click",()=>R({History:{limit:50}}));g("refresh-lobby").addEventListener("click",()=>R("Info"));g("history-back").addEventListener("click",()=>R("ShowLobby"));g("leave-game").addEventListener("click",()=>{!x||(x.screen==="blackjack"&&x.status==="player"||x.screen==="crash"&&x.status==="running")&&!window.confirm("Leaving now forfeits the active bet. Continue?")||R("Leave")});g("reload-replaced").addEventListener("click",()=>location.reload());g("reload-reconnect").addEventListener("click",()=>location.reload());window.addEventListener("resize",()=>{window.clearTimeout(window.__resizeTimer),window.__resizeTimer=window.setTimeout(()=>{x&&Ge()},140)});window.setInterval(()=>{W&&te.ping()},25e3);te.connect();})(); "use strict";(()=>{function x(t){let e=document.getElementById(t);if(!e)throw new Error(`missing #${t}`);return e}function o(t,e,n){let r=document.createElement(t);return e&&(r.className=e),n!==void 0&&(r.textContent=n),r}function v(t){t.replaceChildren()}function Jt(t){let e=t<0,n=String(Math.abs(Math.trunc(t))),r="";for(let s=0;s<n.length;s+=1)s>0&&(n.length-s)%3===0&&(r+=" "),r+=n[s];return e?`-${r}`:r}function T(t){return t==null||Number.isNaN(t)?"— Kr":`${Jt(t)} Kr`}function st(t,e="info"){let n=document.getElementById("toasts");if(!n||!t)return;let r=o("div",`toast ${e}`,t);for(n.append(r),window.setTimeout(()=>{r.style.opacity="0",r.style.transition="opacity 220ms ease",window.setTimeout(()=>r.remove(),240)},4200);n.children.length>4;)n.firstElementChild?.remove()}function yt(t){let e=t.toLowerCase();return t.startsWith("ERROR")||e.includes("failed")||e.includes("lost")||e.includes("invalid")||e.includes("forfeit")?"bad":t.startsWith("Logged in")||t.startsWith("Account created")||e.includes("won")||e.includes("cashed")?"good":"info"}async function xt(t){let e=new TextEncoder().encode(t),n=await crypto.subtle.digest("SHA-256",e);return[...new Uint8Array(n)].map(r=>r.toString(16).padStart(2,"0")).join("")}function j(t,e=10){return t.length<=e*2?t:`${t.slice(0,e)}…${t.slice(-e)}`}function kt(t){if(t==="?"||!t)return{rank:"?",suit:"",red:!1};let e=t.slice(-1);return{rank:t.slice(0,-1),suit:e==="C"?"♣":e==="D"?"♦":e==="H"?"♥":e==="S"?"♠":e,red:e==="D"||e==="H"}}var G=class{constructor(e,n){this.url=e;this.handlers=n;this.socket=null;this.stopped=!1;this.replaced=!1;this.attempts=0}connect(){if(this.stopped)return;this.clearTimer();let e;try{e=new WebSocket(this.url)}catch{this.scheduleReconnect()||this.handlers.onClosed(!1);return}this.socket=e,e.addEventListener("open",()=>{this.attempts=0,this.handlers.onOpen()}),e.addEventListener("message",n=>{let r;try{r=JSON.parse(String(n.data))}catch{return}switch(r.type){case"hello":this.handlers.onHello(r);break;case"view":this.handlers.onView(r.view);break;case"notice":this.handlers.onNotice(String(r.text??""));break;case"error":this.handlers.onError(String(r.text??""));break;case"replaced":this.replaced=!0,this.handlers.onReplaced();break;case"pong":this.handlers.onPong();break}}),e.addEventListener("close",()=>{if(this.socket===e&&(this.socket=null),this.replaced||this.stopped){this.handlers.onClosed(!1);return}let r=this.scheduleReconnect();this.handlers.onClosed(r)}),e.addEventListener("error",()=>{})}send(e){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"command",command:e}))}ping(){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"ping"}))}close(){this.stopped=!0,this.clearTimer(),this.socket?.close()}scheduleReconnect(){if(this.stopped||this.replaced||this.attempts>=6)return!1;this.attempts+=1;let e=Math.min(250*2**(this.attempts-1),4e3);return this.timer=window.setTimeout(()=>this.connect(),e),!0}clearTimer(){this.timer!==void 0&&(window.clearTimeout(this.timer),this.timer=void 0)}};var Tt=0;function rt(){return Date.now()-Tt<400?!1:(Tt=Date.now(),!0)}function Qt(t){if(t==="?"){let r=o("div","card back");return r.append(o("span","rank","?"),o("span","suit","")),r}let e=kt(t),n=o("div","card");return e.red&&n.classList.add("red"),n.append(o("span","rank",e.rank),o("span","suit",e.suit)),n}function Lt(t,e,n){let r=o("div","hand"),s=o("div","hand-head");s.append(o("span","label",t),o("strong","total",String(n)));let a=o("div","cards");for(let l of e)a.append(Qt(l));return r.append(s,a),r}function te(t,e){let n=String(e?.result??""),r=Number(e?.winnings??0);switch(n){case"blackjack":return`Blackjack — won ${T(r)}`;case"win":return`You win — ${T(r)}`;case"push":return`Push — ${T(r)} returned`;case"bust":return"Bust — the bet is lost";case"lose":return"Dealer wins — the bet is lost";case"forfeit":return"Hand forfeited — the bet is lost";case"playing":return t.status==="player"?"Your move.":"Dealing…";default:return"Ready to deal."}}function wt(t,e,n,r){v(t),v(e);let s=n.hand,a=s?.phase==="player"||n.status==="player",l=s?.phase==="done"||n.status==="done",i=!s||l,d=o("div","bj-table");if(s)d.append(Lt("DEALER",s.dealer??[],Number(s.dealer_total??0))),d.append(o("div","bj-divider")),d.append(Lt("YOU",s.player??[],Number(s.player_total??0)));else{let b=o("div","empty-table","Place a bet and deal a hand.");d.append(b)}t.append(d);let m=o("div","board-foot");m.append(o("span","label","Hand"),o("span",void 0,s?te(n,s):"Waiting")),t.append(m),n.error&&t.append(o("p","error-text",String(n.error))),e.append(o("h2",void 0,"Blackjack"));let f=Number(s?.bet??n.bet??0),L=n.balance??n.hand?.balance;if(e.append(o("p","note","Get closer to 21 without going over.")),i){let b=o("div","row"),y=o("button","btn small","−10"),S=o("strong","mono",T(f)),c=o("button","btn small","+10");y.addEventListener("click",()=>r({AdjustBet:-10})),c.addEventListener("click",()=>r({AdjustBet:10})),b.append(y,S,c),e.append(o("div","label","Bet"),b)}else{let b=o("div","stat");b.append(o("span","muted","Bet"),o("strong",void 0,T(f))),e.append(b)}if(L!==void 0){let b=o("div","stat");b.append(o("span","muted","Balance"),o("strong",void 0,T(Number(L)))),e.append(b)}if(l||s&&!a){let b=o("p","note win-text");b.textContent=`${String(s?.result??"").toUpperCase()} ${T(Number(s?.winnings??0))}`,["win","blackjack","push"].includes(String(s?.result))||(b.className="note"),e.append(b)}else if(a){let b=(s?.actions??[]).includes("double")?"Hit, stand or double.":"Hit or stand.";e.append(o("p","note",b))}else s||e.append(o("p","note","Press DEAL to start a hand."));let w=o("div","actions");if(a){let b=o("div","row wrap");for(let S of s?.actions??[]){let c=S==="hit"?"HIT":S==="stand"?"STAND":"DOUBLE",p=S==="stand"?o("button","btn primary",c):o("button","btn",c);p.addEventListener("click",()=>{rt()&&r(S==="hit"?"Hit":S==="stand"?"Stand":"Double")}),b.append(p)}w.append(b);let y=o("button","btn danger wide","Forfeit hand");y.addEventListener("click",()=>{rt()&&window.confirm("Forfeit this hand? The reserved bet is lost.")&&r("Forfeit")}),w.append(y)}else if(i){let b=o("button","btn primary wide big","DEAL");b.addEventListener("click",()=>{rt()&&r("Deal")}),w.append(b)}let k=o("button","btn ghost wide",a?"Leave (forfeits)":"Leave table");k.addEventListener("click",()=>{a&&!window.confirm("Leaving forfeits this hand. Continue?")||r("Leave")}),w.append(k),e.append(w)}var at=Math.LN2/5,B=null,z=null,Mt=null,D=null,$,Et=0,X="",St=0;function ee(t,e,n,r){v(t),v(e),D=n,Mt=r;let s=o("div","crash-wrap"),a=o("div","crash-number","1.00×"),l=o("div","label","READY"),i=o("div","crash-meta"),d=o("canvas","crash-canvas"),m=o("div","verify","Waiting for a round commitment.");s.append(a,l,i,d,m),t.append(s),e.append(o("h2",void 0,"Crash")),e.append(o("p","note","Cash out before the multiplier crashes."));let f=o("div","row"),L=o("button","btn small","−"),w=o("strong","mono","—"),k=o("button","btn small","+");L.addEventListener("click",()=>D?.({AdjustBet:-10})),k.addEventListener("click",()=>D?.({AdjustBet:10})),f.append(L,w,k),e.append(o("div","label","Bet"),f);let b=o("strong","mono","—"),y=o("strong","mono","—");for(let[R,E]of[["Potential",b],["Balance",y]]){let M=o("div","stat");M.append(o("span","muted",R),E),e.append(M)}let S=o("p","note","Press START to play."),c=o("p","note",""),p=o("p","error-text");e.append(S,c,p);let h=o("div","actions"),u=o("button","btn primary wide big","START"),g=o("button","btn ghost wide","Leave table");return u.addEventListener("click",()=>{Date.now()-St<500||(St=Date.now(),z?.status==="running"?D?.("CashOut"):D?.("Start"))}),g.addEventListener("click",()=>{z?.status==="running"&&!window.confirm("Forfeit this round? The bet is lost.")||D?.("Leave")}),h.append(u,g),e.append(h),{board:t,side:e,number:a,meta:i,verify:m,canvas:d,bet:w,potential:b,balance:y,status:S,lastCrash:c,error:p,primary:u,leave:g,minus:L,plus:k}}function Nt(){$!==void 0&&(cancelAnimationFrame($),$=void 0),z=null}function vt(t,e,n,r,s){(!B||B.board!==t||!B.canvas.isConnected)&&(B=ee(t,e,r,s),X="");let a=B;D=r,Mt=s,z=n,n.status!==X&&((n.status==="starting"||n.status==="running")&&X!=="running"&&(a.verify.className="verify",a.verify.textContent="Round committed; seed hidden until the round ends."),X=String(n.status));let l=n.status==="running",i=n.status==="starting",d=l||i;a.bet.textContent=T(Number(n.bet??0)),a.potential.textContent=T(Number(n.potential??n.bet??0)),a.balance.textContent=n.balance===null||n.balance===void 0?"—":T(Number(n.balance)),a.error.textContent=n.error?String(n.error):"",a.minus.disabled=d,a.plus.disabled=d,a.primary.textContent=l?"CASH OUT":i?"STARTING…":"START",a.primary.disabled=i,a.primary.className="btn primary wide big",a.leave.textContent=l?"Forfeit round":"Leave table",l?(a.status.textContent="Rising — cash out before the crash.",a.status.className="note win-text"):i?(a.status.textContent="Starting the round…",a.status.className="note"):n.status==="cashed"?(a.status.textContent=`Cashed out at ${Number(n.multiplier??0).toFixed(2)}× for ${T(Number(n.payout??0))}.`,a.status.className="note win-text"):n.status==="crashed"?(a.status.textContent=`Crashed at ${Number(n.crash_point??n.multiplier??0).toFixed(2)}×. The bet is lost.`,a.status.className="note error-text"):n.status==="forfeit"?(a.status.textContent="Round forfeited.",a.status.className="note error-text"):(a.status.textContent="Press START to play.",a.status.className="note"),n.crash_point!==null&&n.crash_point!==void 0&&n.status==="crashed"&&(a.lastCrash.textContent=`Last crash: ${Number(n.crash_point).toFixed(2)}×`);let m=n.commit?String(n.commit):"";if(a.meta.textContent=m?`commit ${j(m,12)}`:"",a.meta.style.display=m?"":"none",a.verify.style.display=m?"":"none",n.seed&&m){let f=++Et,L=String(n.seed),w=Number(n.crash_point??0);se(L,m,w).then(([k,b])=>{if(f!==Et||!B)return;let y=k&&b;B.verify.className=`verify ${y?"ok":"bad"}`,B.verify.textContent=y?`Seed ${j(L,12)} · sha256 matches commit · crash point recomputed`:`VERIFICATION FAILED · seed ${j(L,12)} · commit ${j(m,12)}`})}else!d&&!m&&(a.verify.className="verify",a.verify.textContent="Waiting for a round commitment.");if($!==void 0&&(cancelAnimationFrame($),$=void 0),l){let f=()=>{if(!B||z?.status!=="running"){$=void 0;return}Ct(B,z,s),$=requestAnimationFrame(f)};$=requestAnimationFrame(f)}else Ct(a,n,s)}function ne(t,e){if(t.status==="running"){let n=Math.max(0,(e.now()-Number(t.started_at??0))/1e3),r=Math.min(1e3,Math.exp(at*n));return Math.max(r,Number(t.multiplier??1))}return t.status==="cashed"?Number(t.multiplier??1):t.status==="crashed"?Number(t.crash_point??t.multiplier??1):1}function Ct(t,e,n){let r=ne(e,n);t.number.textContent=`${r.toFixed(2)}×`;let s=t.number.nextElementSibling;s&&(s.textContent=e.status==="running"?"RUNNING":e.status==="cashed"?"CASHED OUT":e.status==="crashed"?"CRASHED":e.status==="starting"?"STARTING":"READY"),t.number.className=`crash-number ${e.status==="running"?"running":e.status==="cashed"?"cashed":e.status==="crashed"?"crashed":""}`,t.potential.textContent=T(Math.floor(Number(e.bet??0)*r)),oe(t.canvas,e,r,n)}function oe(t,e,n,r){let s=t.clientWidth||600,a=t.clientHeight||360,l=window.devicePixelRatio||1;(t.width!==Math.round(s*l)||t.height!==Math.round(a*l))&&(t.width=Math.round(s*l),t.height=Math.round(a*l));let i=t.getContext("2d");if(!i)return;i.setTransform(l,0,0,l,0,0),i.clearRect(0,0,s,a);let d=54,m=16,f=18,L=34,w=Math.max(1,s-d-m),k=Math.max(1,a-f-L),b=e.status==="running"?Math.max(.25,(r.now()-Number(e.started_at??0))/1e3):n>1?Math.log(n)/at:1,y=Math.max(5,b*1.08),S=Math.max(2,Math.ceil(n*1.15*2)/2),c=E=>d+E/y*w,p=E=>f+k-(E-1)/(S-1)*k;i.strokeStyle="#242e38",i.fillStyle="#8a98a6",i.font="11px ui-monospace, monospace",i.textAlign="left",i.textBaseline="middle",i.lineWidth=1;for(let E=0;E<=4;E+=1){let M=1+(S-1)*E/4,C=p(M);i.beginPath(),i.moveTo(d,C),i.lineTo(s-m,C),i.stroke(),i.fillText(`${M.toFixed(1)}×`,10,C)}for(let E=0;E<=4;E+=1){let M=y*E/4,C=c(M);i.beginPath(),i.moveTo(C,f),i.lineTo(C,f+k),i.strokeStyle="#1b232c",i.stroke(),i.fillStyle="#5b6773",i.textAlign="center",i.textBaseline="alphabetic",i.fillText(`${M.toFixed(0)}s`,C,a-12),i.textAlign="left",i.textBaseline="middle"}if(!["running","cashed","crashed"].includes(String(e.status))){i.fillStyle="#5b6773",i.textAlign="center",i.textBaseline="middle",i.font='13px "Open Sans", sans-serif',i.fillText("Press START to play",d+w/2,f+k/2);return}let h=e.status==="crashed"?"#e05a5a":e.status==="cashed"?"#4ec27b":"#e0b64a";i.beginPath();let u=180;for(let E=0;E<=u;E+=1){let M=b*E/u,C=Math.min(S,Math.exp(at*M)),H=c(M),A=p(C);E===0?i.moveTo(H,A):i.lineTo(H,A)}i.strokeStyle=h,i.lineWidth=2.4,i.stroke();let g=c(b),R=p(Math.min(S,n));i.beginPath(),i.arc(g,R,4.5,0,Math.PI*2),i.fillStyle=h,i.fill(),i.fillStyle=h,i.font="600 12px ui-monospace, monospace",i.fillText(`${n.toFixed(2)}×`,Math.min(g+8,s-70),Math.max(f+12,R-10))}async function se(t,e,n){let r=new Uint8Array(t.length/2);for(let f=0;f<r.length;f+=1)r[f]=Number.parseInt(t.slice(f*2,f*2+2),16);let s=await crypto.subtle.digest("SHA-256",r),l=[...new Uint8Array(s)].map(f=>f.toString(16).padStart(2,"0")).join("")===e.toLowerCase(),i=0;for(let f=0;f<8;f+=1)i=i*256+r[f];i/=2**64;let d=Math.max(1,Math.min(1e3,(1-.01)/(1-i))),m=Math.abs(d-n)<1e-6;return[l,m]}function re(t){let e=t.details??{};switch(t.game){case"slots":return String(e.win_type??"spin");case"roulette":return`${e.number??"?"} ${e.color??""}`.trim();case"blackjack":return String(e.result??"");case"crash":{let n=String(e.result??""),r=e.crash_point;return r==null?n:`${n} @ ${Number(r).toFixed(2)}×`}default:return""}}function Ht(t,e){v(t);let n=e.records??[];if(n.length===0){let d=o("div","board");d.append(o("h2",void 0,"No plays yet"),o("p","muted","Your settled plays will appear here.")),t.append(d);return}let r=o("div","table-wrap"),s=o("table"),a=o("thead"),l=o("tr");for(let d of["When","Game","Bet","Won","Balance","Result"])l.append(o("th",void 0,d));a.append(l);let i=o("tbody");for(let d of n){let m=o("tr");m.append(o("td","mono",String(d.at??"").slice(0,16))),m.append(o("td","mono",String(d.game??"").toUpperCase())),m.append(o("td","num",T(Number(d.bet??0))));let f=Number(d.winnings??0);m.append(o("td",`num ${f>0?"pos":""}`,T(f))),m.append(o("td","num",T(Number(d.balance_after??0)))),m.append(o("td","muted",re(d))),i.append(m)}s.append(a,i),r.append(s),t.append(r)}var it=[0,32,15,19,4,21,2,25,17,34,6,27,13,36,11,30,8,23,10,5,24,16,33,1,20,14,31,9,22,18,29,7,28,12,35,3,26],ae=new Set([1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]),Wt="#b0313a",It="#151b21",Ot="#106b45",At="#0d5c39",ct="#1f7d52",dt="#ecf7f0",ie="#e0b64a",le="#11171d",lt=null;function ut(t){return t===0?"green":ae.has(t)?"red":"black"}function ce(t,e){let r=Math.max(44,t*.058),s=Math.max(28,Math.min(38,e*.075)),a=t-28-r,l=a/12,i=(e-28-s*3)/3,d=14+r,m=14,f=m+i*3,L=f+s,w=L+s,k=[];for(let c=0;c<12;c+=1)for(let p=0;p<3;p+=1){let h=c*3+(3-p);k.push({x:d+c*l,y:m+p*i,w:l,h:i,target:{bet:"straight",number:h},label:String(h),kind:"number",number:h})}k.push({x:14,y:m,w:r,h:i*3,target:{bet:"straight",number:0},label:"0",kind:"zero",number:0});for(let c=0;c<3;c+=1)k.push({x:d+a/3*c,y:f,w:a/3,h:s,target:{bet:"dozen",index:c},label:["1st 12","2nd 12","3rd 12"][c],kind:"dozen"}),k.push({x:d+a/3*c,y:L,w:a/3,h:s,target:{bet:"column",index:2-c},label:"2:1",kind:"column"});[{kind:"low",label:"1–18"},{kind:"even",label:"EVEN"},{kind:"red",label:"RED"},{kind:"black",label:"BLACK"},{kind:"odd",label:"ODD"},{kind:"high",label:"19–36"}].forEach((c,p)=>{k.push({x:d+a/6*p,y:w,w:a/6,h:s,target:{bet:"outside",kind:c.kind},label:c.label,kind:"outside"})});let y=[],S=c=>{let p=k.find(h=>h.kind==="number"&&h.number===c);return p?{x:p.x+p.w/2,y:p.y+p.h/2}:null};for(let c=0;c<11;c+=1)for(let p=0;p<2;p+=1){let h=c*3+(3-p);y.push({x:d+(c+1)*l,y:m+(p+1)*i,radius:12,rank:0,target:{bet:"corner",numbers:[h,h-1,h+3,h+2]}})}for(let c=0;c<12;c+=1)for(let p=0;p<2;p+=1){let h=c*3+(3-p),u=S(h);u&&y.push({x:u.x,y:m+(p+1)*i,radius:10,rank:1,target:{bet:"split",a:h,b:h-1}})}for(let c=0;c<11;c+=1)for(let p=0;p<3;p+=1){let h=c*3+(3-p);y.push({x:d+(c+1)*l,y:m+p*i+i/2,radius:10,rank:1,target:{bet:"split",a:h,b:h+3}})}for(let c=0;c<3;c+=1)y.push({x:d,y:m+c*i+i/2,radius:10,rank:1,target:{bet:"split",a:0,b:3-c}});return{width:t,height:e,pad:14,cellW:l,cellH:i,zeroX:14,zeroW:r,gridX:d,gridY:m,gridW:a,dozensY:f,columnsY:L,outsideY:w,outsideH:s,zones:k,hotspots:y}}function Rt(t,e,n,r,s){t.beginPath(),t.rect(e,n,r,s),t.closePath()}function Bt(t,e){let n=t.clientWidth||900,r=t.clientHeight||500,s=window.devicePixelRatio||1;(t.width!==Math.round(n*s)||t.height!==Math.round(r*s))&&(t.width=Math.round(n*s),t.height=Math.round(r*s));let a=t.getContext("2d"),l=ce(n,r);if(!a)return l;a.setTransform(s,0,0,s,0,0),a.clearRect(0,0,n,r),a.fillStyle=At,Rt(a,l.pad,l.gridY,l.zeroW+l.gridW,l.cellH*3+l.outsideH*3),a.fill();for(let i of l.zones){let d=At;i.kind==="number"?d=ut(i.number)==="red"?Wt:It:i.kind==="zero"&&(d=Ot),a.fillStyle=d,a.strokeStyle=ct,a.lineWidth=1,Rt(a,i.x+.5,i.y+.5,i.w-1,i.h-1),a.fill(),a.stroke(),a.fillStyle=dt,a.textAlign="center",a.textBaseline="middle",i.kind==="number"||i.kind==="zero"?a.font=`500 ${Math.min(18,l.cellH*.16)}px "Open Sans", sans-serif`:a.font=`500 ${Math.min(12,l.outsideH*.4)}px "Open Sans", sans-serif`,i.label==="RED"&&(a.fillStyle="#e05a5a"),i.label==="BLACK"&&(a.fillStyle="#8a98a6"),a.fillText(i.label,i.x+i.w/2,i.y+i.h/2)}return ue(a,l,e),l}function Pt(t){return JSON.stringify(t)}function de(t,e){let n=Pt(e),r=t.zones.find(s=>Pt(s.target)===n);if(r)return{x:r.x+r.w/2,y:r.y+r.h/2};if(e?.bet==="split"||e?.bet==="corner"){let s=e.bet==="split"?[e.a,e.b]:e.numbers,a=t.zones.filter(l=>l.kind==="number"&&s.includes(l.number));if(a.length>0)return{x:a.reduce((l,i)=>l+i.x+i.w/2,0)/a.length,y:a.reduce((l,i)=>l+i.y+i.h/2,0)/a.length}}return null}function ue(t,e,n){let r=n.bets?.chips??[];for(let s of r){let a=de(e,s.target);if(!a)continue;let l=Math.max(12,Math.min(17,e.cellW*.28));t.beginPath(),t.arc(a.x,a.y,l,0,Math.PI*2),t.fillStyle=ie,t.fill(),t.strokeStyle="#7a5a12",t.lineWidth=2,t.stroke(),t.fillStyle="#221a05",t.textAlign="center",t.textBaseline="middle",t.font=`700 ${l>15?11:10}px "Hack", monospace`,t.fillText(String(s.amount),a.x,a.y+.5)}}function pe(t,e){let r=window.devicePixelRatio||1;t.width=Math.round(170*r),t.height=Math.round(170*r),t.style.width="170px",t.style.height="170px";let s=t.getContext("2d");if(!s)return;s.setTransform(r,0,0,r,0,0);let a=e.phase==="result"&&e.number!==null&&e.number!==void 0?Number(e.number):null,l=a===null?-1:it.indexOf(a),i=Math.PI*2/it.length,d=l<0?0:-l*i,m=performance.now(),f=650,L=k=>{s.clearRect(0,0,170,170),s.fillStyle=le,s.beginPath(),s.arc(85,85,78.2+6,0,Math.PI*2),s.fill(),it.forEach((p,h)=>{let u=-Math.PI/2+h*i+k;s.beginPath(),s.moveTo(85,85),s.arc(85,85,78.2,u-i/2,u+i/2),s.closePath(),s.fillStyle=ut(p)==="red"?Wt:ut(p)==="green"?Ot:It,s.fill(),s.strokeStyle=ct,s.lineWidth=.8,s.stroke()}),s.beginPath(),s.arc(85,85,49.3,0,Math.PI*2),s.fillStyle="#0a0e12",s.fill(),s.strokeStyle=ct,s.lineWidth=1,s.stroke(),s.beginPath(),s.moveTo(85,8),s.lineTo(80,18),s.lineTo(90,18),s.closePath(),s.fillStyle=dt,s.fill(),a!==null&&(s.fillStyle=dt,s.textAlign="center",s.textBaseline="middle",s.font='600 15px "Hack", monospace',s.fillText(String(a),85,85))},w=k=>{let b=Math.min(1,(k-m)/f),y=1-(1-b)**3;L(d*y),b<1&&requestAnimationFrame(w)};requestAnimationFrame(w)}function Dt(t,e,n,r){v(t),v(e);let s=n.phase==="spinning",a=o("canvas","roulette-canvas");t.append(a);let l=o("div","board-foot tight");if(l.append(o("span","label","Last")),n.phase==="result"&&n.number!==null&&n.number!==void 0){let u=o("span",n.winnings&&Number(n.winnings)>0?"win-text":void 0,`${n.number} ${n.color} · ${Number(n.winnings??0)>0?T(Number(n.winnings)):"no win"}`);l.append(u)}else l.append(o("span",void 0,"No spins yet"));l.append(o("span","note","Click to place a chip · right-click to take one back")),t.append(l);let d=Bt(a,n),m=(u,g)=>{let R=a.getBoundingClientRect(),E=u-R.left,M=g-R.top,C=null;for(let H of d.hotspots){let A=Math.hypot(E-H.x,M-H.y);A>H.radius||(!C||H.rank<C.rank||H.rank===C.rank&&A<Math.hypot(E-C.x,M-C.y))&&(C=H)}if(C)return{x:C.x-C.radius,y:C.y-C.radius,w:C.radius*2,h:C.radius*2,target:C.target,label:"",kind:"number"};for(let H=d.zones.length-1;H>=0;H-=1){let A=d.zones[H];if(E>=A.x&&E<=A.x+A.w&&M>=A.y&&M<=A.y+A.h)return A}return null};a.addEventListener("click",u=>{if(s)return;let g=m(u.clientX,u.clientY);g&&r({PlaceBetAt:g.target})}),a.addEventListener("contextmenu",u=>{if(u.preventDefault(),s)return;let g=m(u.clientX,u.clientY);g&&r({RemoveBetAt:g.target})}),a.addEventListener("mousemove",u=>{let g=m(u.clientX,u.clientY);a.style.cursor=g&&!s?"pointer":"default"}),lt?.disconnect(),lt=new ResizeObserver(()=>{d=Bt(a,n)}),lt.observe(a),e.append(o("h2",void 0,"Roulette")),e.append(o("p","note","Pick numbers or colours, then spin."));let f=o("canvas","wheel-canvas");e.append(f),pe(f,n),e.append(o("div","label","Place your bets"));let L=(n.bets?.chips??[]).reduce((u,g)=>u+Number(g.amount??0),0);e.append(o("p","note",`${L} ${L===1?"chip":"chips"}`)),e.append(o("div","label","Chip"));let w=o("div","chip-row");for(let u of[10,25,50,100]){let g=o("button",`chip-btn ${Number(n.chip)===u?"active":""}`,String(u));g.dataset.chip=String(u),g.disabled=s,g.addEventListener("click",()=>r({SetChip:u})),w.append(g)}e.append(w),e.append(o("div","label","Bets"));let k=n.bets?.chips??[];if(k.length===0)e.append(o("p","note","No chips on the felt"));else for(let u of k){let g=o("div","stat");g.append(o("span","muted",me(u.target)),o("strong","mono",T(Number(u.amount??0)))),e.append(g)}let b=o("div","stat");b.append(o("span","muted","Total"),o("strong","mono",T(L))),e.append(b),n.error&&e.append(o("p","error-text",String(n.error)));let y=o("p","note");if(s)y.textContent="SPINNING…";else if(n.phase==="result"){let u=Number(n.winnings??0);y.textContent=u>0?`WON ${T(u)}`:"NO WIN",u>0&&y.classList.add("win-text")}else y.textContent=L>0?"Ready to spin.":"Place at least one chip.";e.append(y);let S=o("div","actions"),c=o("button","btn primary wide big",s?"SPINNING…":"SPIN");c.disabled=s||L===0,c.addEventListener("click",()=>{Date.now()-$t<600||($t=Date.now(),r("Spin"))});let p=o("button","btn wide","CLEAR");p.disabled=s||L===0,p.addEventListener("click",()=>r("ClearBets"));let h=o("button","btn ghost wide","Leave table");h.disabled=s,h.addEventListener("click",()=>r("Leave")),S.append(c,p,h),e.append(S)}var $t=0;function me(t){switch(t?.bet){case"straight":return String(t.number);case"split":return`${t.a}/${t.b}`;case"corner":return t.numbers.join("/");case"dozen":return["1st 12","2nd 12","3rd 12"][t.index]??"dozen";case"column":return`col ${Number(t.index)+1}`;case"outside":return String(t.kind??"");default:return""}}var pt=5,mt=3,F="CLOGDS",he=["top","middle","bottom","V","inverted V"],U=0,zt=0,W,K=null,Z=null;function fe(){U=Date.now()+900}function Ft(){W!==void 0&&(window.clearInterval(W),W=void 0),K=null,Z=null,U=0}function be(t){return t.phase==="spinning"||Date.now()<U}function ht(t,e,n,r){K=n,Z=t,n.error&&(U=0),W!==void 0&&(window.clearInterval(W),W=void 0);let s=be(n);v(t),v(e);let a=o("div","reels"),l=[],i=Array.isArray(n.reels)?n.reels.join(""):String(n.reels??"");for(let h=0;h<pt;h+=1){let u=o("div","reel");for(let g=0;g<mt;g+=1){let R=h*mt+g,E=s?F[(R*3+Math.floor(Date.now()/70))%F.length]:i.length===pt*mt?i[R]:i.length===pt?i[h]:F[(R*5+h)%F.length],M=o("div","tile",E);M.classList.add(`sym-${E}`),s&&M.classList.add("spinning"),!s&&(n.win_cells??[]).includes(R)&&M.classList.add("win"),l.push(M),u.append(M)}a.append(u)}t.append(a);let d=o("div","win-lines"),m="Ready";if(!s&&n.phase==="result"){let h=Number(n.winnings??0);if(h>0){let u=(n.win_lines??[]).map(g=>`${g} (${he[g-1]??"line"})`).join(" · ");m=`${n.win_type==="mega"?"MEGA WIN":n.win_type==="big"?"BIG WIN":"WIN"} ${T(h)}${u?` · ${u}`:""}`,d.textContent=u,d.classList.add("win-text")}else m="No win"}else s&&(m="Spinning…");t.append(d);let f=o("div","board-foot");if(f.append(o("span","label","Result"),o("span",void 0,m)),t.append(f),s){let h=0;W=window.setInterval(()=>{h+=1;for(let u=0;u<l.length;u+=1){let g=F[(u*3+h)%F.length];l[u].textContent=g,l[u].className=`tile spinning sym-${g}`}Date.now()>=U&&K&&Z&&(window.clearInterval(W),W=void 0,ht(Z,e,K,r))},70)}e.append(o("h2",void 0,"Slots")),e.append(o("p","note","Match three or more symbols from the left."));let L=o("div","row"),w=o("button","btn small","−"),k=o("strong","mono",T(Number(n.bet??0))),b=o("button","btn small","+");w.disabled=s,b.disabled=s,w.addEventListener("click",()=>r({AdjustBet:-10})),b.addEventListener("click",()=>r({AdjustBet:10})),L.append(w,k,b),e.append(o("div","label","Bet"),L),n.error&&e.append(o("p","error-text",String(n.error)));let y=o("p","note");if(s)y.textContent="SPINNING…";else if(n.phase==="result"){let h=Number(n.winnings??0);y.textContent=h>0?`WON ${T(h)}`:"NO WIN",h>0&&y.classList.add("win-text")}else y.textContent="Press SPIN to play.";e.append(y);let S=o("div","actions"),c=o("button","btn primary wide big",s?"SPINNING…":"SPIN");c.disabled=s,c.addEventListener("click",()=>{Date.now()-zt<900||(zt=Date.now(),fe(),r("Spin"))});let p=o("button","btn ghost wide","Leave machine");p.disabled=s,p.addEventListener("click",()=>r("Leave")),S.append(c,p),e.append(S)}var ge=x("auth-screen"),ye=x("lobby-screen"),xe=x("game-screen"),ke=x("history-screen"),Vt=x("session-bar"),Te=x("account-name"),Le=x("account-balance"),ft=x("game-grid"),q=x("game-board"),J=x("game-side"),we=x("version-pill"),Yt=x("account-number"),Ee=x("lobby-count"),Se=x("history-body"),Ce=x("auth-form"),Gt=x("auth-user"),nt=x("auth-pass"),V=x("auth-error"),O=x("auth-submit"),Xt=x("auth-switch"),Me=x("auth-title"),Q=x("reconnect-overlay"),_t=x("reconnect-message"),jt=x("replaced-overlay"),N=null,I=null,et=null,_="login",bt=null,Kt=0,gt=0,Ut=null,Ne=new Map([["slots",10],["roulette",10],["blackjack",10],["crash",10]]),ve={slots:"Slots",roulette:"Roulette",blackjack:"Blackjack",crash:"Crash"},He={slots:"Spin the reels. Three or more matching symbols from the left win.",roulette:"Put chips on the table, then spin. Straight numbers pay the most.",blackjack:"Get closer to 21 than the dealer without going over.",crash:"Start the round, then cash out before the multiplier crashes."},Ae=location.pathname.includes("/play/")?location.pathname.slice(0,location.pathname.indexOf("/play/")):"",Re=`${location.protocol==="https:"?"wss":"ws"}://${location.host}${Ae}/ws`,tt=new URLSearchParams(location.search).get("ws"),Be=location.hostname==="127.0.0.1"||location.hostname==="localhost"||location.hostname==="::1",Pe=tt&&Be&&(tt.startsWith("ws://")||tt.startsWith("wss://"))?tt:Re,ot=new G(Pe,{onHello(t){Kt=t.time-Date.now(),I=t.user??null,typeof t.balance=="number"&&(et=t.balance),we.textContent=`v${t.version}`},onView(t){t.screen!==Ut&&(x("toasts").replaceChildren(),Ut=t.screen),N=t,t.screen!=="slots"&&Ft(),t.screen!=="crash"&&Nt(),typeof t.balance=="number"&&(et=t.balance),t.screen==="lobby"&&"user"in t&&(I=t.user??null,I?nt.value="":et=null),gt=0,Q.hidden=!0,qt(),I&&N.balance===null&&bt!==I&&(bt=I,ot.send("Info")),I||(bt=null)},onNotice(t){t.startsWith("Connected to:")||t.startsWith("Fingerprint:")||t.startsWith("User:")||t.startsWith("Account:")||t.startsWith("Balance:")||(st(t,yt(t)),t.startsWith("Account created")&&(Zt("login"),nt.value="",Gt.focus()),O.disabled=!1,O.textContent=_==="login"?"LOG IN":"CREATE ACCOUNT")},onError(t){st(t,"bad"),O.disabled=!1,O.textContent=_==="login"?"LOG IN":"CREATE ACCOUNT"},onReplaced(){jt.hidden=!1},onPong(){},onOpen(){Q.hidden=!0},onClosed(t){if(jt.hidden!==!1){if(!t){_t.textContent="The connection could not be restored. Reload the page to try again.",Q.hidden=!1;return}gt+=1,_t.textContent=gt>1?"Still trying to reach the game server…":"Trying to reach the game server…",Q.hidden=!1}}});function P(t){ot.send(t)}function $e(){return Date.now()+Kt}function Zt(t){_=t,Me.textContent=t==="login"?"Sign in":"Create account",O.textContent=t==="login"?"LOG IN":"CREATE ACCOUNT",Xt.textContent=t==="login"?"CREATE ACCOUNT":"BACK TO SIGN IN",nt.autocomplete=t==="login"?"current-password":"new-password",V.textContent=""}function Y(t){ge.hidden=t!=="auth",ye.hidden=t!=="lobby",xe.hidden=t!=="game",ke.hidden=t!=="history"}function We(t){Le.textContent=T(t??null)}function qt(){if(!N)return;let t=I;if(t?(Vt.hidden=!1,Te.textContent=String(t)):(Vt.hidden=!0,Yt.textContent="—"),We(et),!t){Y("auth"),V.textContent=N.login_error?String(N.login_error):"",N.screen==="lobby"&&N.connected===!1&&(V.textContent="The game server is not reachable yet. Retrying…");return}switch(N.screen){case"lobby":Y("lobby"),Ie(N);break;case"history":Y("history"),Ht(Se,N);break;case"slots":case"roulette":case"blackjack":case"crash":Y("game"),N.screen==="slots"?ht(q,J,N,P):N.screen==="roulette"?Dt(q,J,N,P):N.screen==="blackjack"?wt(q,J,N,P):vt(q,J,N,P,{now:$e});break;default:Y("auth")}}function Ie(t){Yt.textContent=t.account?String(t.account):"—";let e=["slots","roulette","blackjack","crash"],n=[...t.games??[]].sort((r,s)=>e.indexOf(r[0])-e.indexOf(s[0]));Ee.textContent=`${n.length} ${n.length===1?"game":"games"}`,ft.replaceChildren();for(let[r,s]of n){let a=o("article",`game-card ${r}`);a.append(o("h3",void 0,s||ve[r]||r)),a.append(o("p",void 0,He[r]??"Play a round."));let l=o("button","btn primary wide","PLAY");l.addEventListener("click",()=>{P({Play:{game:r,bet:Ne.get(r)??10}})}),a.append(l),ft.append(a)}n.length===0&&ft.append(o("p","muted","The server did not advertise any games."))}Xt.addEventListener("click",()=>Zt(_==="login"?"register":"login"));Ce.addEventListener("submit",async t=>{t.preventDefault();let e=Gt.value.trim(),n=nt.value;if(!e||!n){V.textContent="Enter a username and a password.";return}O.disabled=!0,O.textContent="Working…",V.textContent="";try{let r=await xt(n);P(_==="login"?{Login:{user:e,credential:r}}:{Register:{user:e,credential:r}})}catch{V.textContent="This browser cannot hash the password (needs a secure context).",O.disabled=!1,O.textContent=_==="login"?"Log in":"Create account"}});x("logout").addEventListener("click",()=>P("Logout"));x("show-history").addEventListener("click",()=>P({History:{limit:50}}));x("history-back").addEventListener("click",()=>P("ShowLobby"));x("reload-replaced").addEventListener("click",()=>location.reload());x("reload-reconnect").addEventListener("click",()=>location.reload());window.addEventListener("resize",()=>{window.clearTimeout(window.__resizeTimer),window.__resizeTimer=window.setTimeout(()=>{N&&qt()},140)});window.setInterval(()=>{I&&ot.ping()},25e3);ot.connect();})();
diff --git a/web/play/index.html b/web/play/index.html
index 59a302b..7bebe50 100644
--- a/web/play/index.html
+++ b/web/play/index.html
@@ -4,7 +4,7 @@
4<meta charset="utf-8"> 4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1"> 5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>Play — żagań.bet</title> 6<title>Play — żagań.bet</title>
7<meta name="description" content="Play slots, roulette, blackjack and crash with play credits. No real money."> 7<meta name="description" content="Play slots, roulette, blackjack and crash in your browser.">
8<meta name="color-scheme" content="dark"> 8<meta name="color-scheme" content="dark">
9<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; base-uri 'none'; form-action 'self'"> 9<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; base-uri 'none'; form-action 'self'">
10<link rel="stylesheet" href="style.css"> 10<link rel="stylesheet" href="style.css">
@@ -13,12 +13,13 @@
13<div class="shell"> 13<div class="shell">
14 <header class="topbar"> 14 <header class="topbar">
15 <a class="brand" href="/">żagań<span>.bet</span></a> 15 <a class="brand" href="/">żagań<span>.bet</span></a>
16 <span class="tagline">Play credits · no real money</span> 16 <span id="version-pill" class="pill">v5.0.0</span>
17 <span class="tagline">Slots · Roulette · Blackjack · Crash</span>
17 <span class="spacer"></span> 18 <span class="spacer"></span>
18 <div id="session-bar" class="session" hidden> 19 <div id="session-bar" class="session" hidden>
19 <span class="who">Signed in as <strong id="account-name"></strong></span> 20 <span class="who"><span class="label">User</span><strong id="account-name"></strong></span>
20 <span id="account-balance" class="balance">— Kr</span> 21 <span class="who"><span class="label">Account</span><strong id="account-number">—</strong></span>
21 <button id="logout" class="btn ghost small" type="button">Log out</button> 22 <span class="balance-block"><span class="label">Balance</span><span id="account-balance" class="balance">— Kr</span></span>
22 </div> 23 </div>
23 </header> 24 </header>
24 25
@@ -26,12 +27,8 @@
26 <section id="auth-screen" hidden> 27 <section id="auth-screen" hidden>
27 <div class="auth-wrap"> 28 <div class="auth-wrap">
28 <div class="auth-card"> 29 <div class="auth-card">
29 <h1>Play żagań.bet</h1> 30 <h1 id="auth-title">Sign in</h1>
30 <p class="muted" style="margin: 6px 0 20px">Play-money casino. Your balance is play credit; nothing here is real money.</p> 31 <p class="muted" style="margin: 6px 0 20px">Sign in to play.</p>
31 <div class="auth-tabs" role="tablist">
32 <button id="tab-login" class="auth-tab active" type="button" role="tab">Log in</button>
33 <button id="tab-register" class="auth-tab" type="button" role="tab">Register</button>
34 </div>
35 <form id="auth-form"> 32 <form id="auth-form">
36 <label class="field"> 33 <label class="field">
37 <span class="label">Username</span> 34 <span class="label">Username</span>
@@ -42,34 +39,25 @@
42 <input id="auth-pass" class="input" type="password" autocomplete="current-password" required> 39 <input id="auth-pass" class="input" type="password" autocomplete="current-password" required>
43 </label> 40 </label>
44 <p id="auth-error" class="error-text" style="margin: 0; min-height: 18px"></p> 41 <p id="auth-error" class="error-text" style="margin: 0; min-height: 18px"></p>
45 <button id="auth-submit" class="btn primary wide big" type="submit">Log in</button> 42 <button id="auth-submit" class="btn primary wide big" type="submit">LOG IN</button>
43 <button id="auth-switch" class="btn ghost wide" type="button">CREATE ACCOUNT</button>
46 </form> 44 </form>
47 <p class="auth-foot">Registration gives you 1 000 Kr of play credit. No deposits, no withdrawals, no personal data beyond a username.</p> 45 <p class="auth-foot">Registration gives you 1 000 Kr. No personal data beyond a username.</p>
48 </div> 46 </div>
49 </div> 47 </div>
50 </section> 48 </section>
51 49
52 <section id="lobby-screen" hidden> 50 <section id="lobby-screen" hidden>
53 <div class="lobby-head"> 51 <div class="lobby-head">
54 <div> 52 <h1>Lobby <span id="lobby-count" class="count">4 games</span></h1>
55 <h1>Lobby</h1>
56 <p class="muted" style="margin: 4px 0 0">Choose a game. Bets are play credits.</p>
57 </div>
58 <span id="lobby-account" class="label"></span>
59 <span class="spacer"></span> 53 <span class="spacer"></span>
60 <button id="show-history" class="btn ghost" type="button">Play history</button> 54 <button id="show-history" class="btn ghost small" type="button">HISTORY</button>
61 <button id="refresh-lobby" class="btn ghost" type="button">Refresh</button> 55 <button id="logout" class="btn ghost small" type="button">LOG OUT</button>
62 </div> 56 </div>
63 <div id="game-grid" class="game-grid"></div> 57 <div id="game-grid" class="game-grid"></div>
64 </section> 58 </section>
65 59
66 <section id="game-screen" hidden> 60 <section id="game-screen" hidden>
67 <div class="game-head">
68 <button id="leave-game" class="btn ghost" type="button">← Lobby</button>
69 <h1 id="game-title">Game</h1>
70 <span class="spacer"></span>
71 <span id="game-badge" class="label"></span>
72 </div>
73 <div class="game-body"> 61 <div class="game-body">
74 <div id="game-board" class="board"></div> 62 <div id="game-board" class="board"></div>
75 <aside id="game-side" class="sidebar"></aside> 63 <aside id="game-side" class="sidebar"></aside>
@@ -89,7 +77,7 @@
89 </section> 77 </section>
90 </main> 78 </main>
91 <footer class="site-foot"> 79 <footer class="site-foot">
92 Play-money only · built from <a href="https://git.2137697.xyz/zagan-bet.git">the GPL source</a> 80 Built from <a href="https://git.2137697.xyz/zagan-bet.git">the GPL source</a>
93 </footer> 81 </footer>
94</div> 82</div>
95 83
diff --git a/web/play/src/blackjack.ts b/web/play/src/blackjack.ts
index 49d46fa..3288244 100644
--- a/web/play/src/blackjack.ts
+++ b/web/play/src/blackjack.ts
@@ -24,12 +24,11 @@ function cardElement(card: string): HTMLElement {
24 24
25function handBlock(label: string, cards: string[], total: number): HTMLElement { 25function handBlock(label: string, cards: string[], total: number): HTMLElement {
26 const block = node("div", "hand"); 26 const block = node("div", "hand");
27 block.append(node("div", "label", label)); 27 const head = node("div", "hand-head");
28 head.append(node("span", "label", label), node("strong", "total", String(total)));
28 const row = node("div", "cards"); 29 const row = node("div", "cards");
29 for (const card of cards) row.append(cardElement(card)); 30 for (const card of cards) row.append(cardElement(card));
30 const sum = node("div", "total"); 31 block.append(head, row);
31 sum.append(document.createTextNode("Total "), node("strong", undefined, String(total)));
32 block.append(row, sum);
33 return block; 32 return block;
34} 33}
35 34
@@ -70,36 +69,31 @@ export function renderBlackjack(
70 const done = hand?.phase === "done" || view.status === "done"; 69 const done = hand?.phase === "done" || view.status === "done";
71 const canDeal = !hand || done; 70 const canDeal = !hand || done;
72 71
73 board.append(node("h2", undefined, "Blackjack")); 72 const table = node("div", "bj-table");
74 board.append(
75 node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."),
76 );
77
78 if (hand) { 73 if (hand) {
79 board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); 74 table.append(handBlock("DEALER", hand.dealer ?? [], Number(hand.dealer_total ?? 0)));
80 board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0))); 75 table.append(node("div", "bj-divider"));
76 table.append(handBlock("YOU", hand.player ?? [], Number(hand.player_total ?? 0)));
81 } else { 77 } else {
82 const empty = node("div", "outcome"); 78 const empty = node("div", "empty-table", "Place a bet and deal a hand.");
83 empty.textContent = "Press DEAL to start a hand."; 79 table.append(empty);
84 board.append(empty);
85 } 80 }
81 board.append(table);
86 82
87 if (done || (hand && !playerPhase)) { 83 const foot = node("div", "board-foot");
88 const result = node("div", "outcome"); 84 foot.append(
89 result.textContent = outcome(view, hand); 85 node("span", "label", "Hand"),
90 if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") { 86 node("span", undefined, hand ? outcome(view, hand) : "Waiting"),
91 result.classList.add("win-text"); 87 );
92 } 88 board.append(foot);
93 board.append(result);
94 }
95 89
96 if (view.error) board.append(node("p", "error-text", String(view.error))); 90 if (view.error) board.append(node("p", "error-text", String(view.error)));
97 91
98 // Sidebar 92 // Sidebar
99 side.append(node("div", "label", "Blackjack")); 93 side.append(node("h2", undefined, "Blackjack"));
100 const bet = Number(hand?.bet ?? view.bet ?? 0); 94 const bet = Number(hand?.bet ?? view.bet ?? 0);
101 const balance = view.balance ?? view.hand?.balance; 95 const balance = view.balance ?? view.hand?.balance;
102 side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet.")); 96 side.append(node("p", "note", "Get closer to 21 without going over."));
103 97
104 if (canDeal) { 98 if (canDeal) {
105 const betRow = node("div", "row"); 99 const betRow = node("div", "row");
@@ -122,6 +116,22 @@ export function renderBlackjack(
122 side.append(stat); 116 side.append(stat);
123 } 117 }
124 118
119 if (done || (hand && !playerPhase)) {
120 const resultLine = node("p", "note win-text");
121 resultLine.textContent = `${String(hand?.result ?? "").toUpperCase()} ${money(Number(hand?.winnings ?? 0))}`;
122 if (!["win", "blackjack", "push"].includes(String(hand?.result))) {
123 resultLine.className = "note";
124 }
125 side.append(resultLine);
126 } else if (playerPhase) {
127 const actionsText = (hand?.actions ?? []).includes("double")
128 ? "Hit, stand or double."
129 : "Hit or stand.";
130 side.append(node("p", "note", actionsText));
131 } else if (!hand) {
132 side.append(node("p", "note", "Press DEAL to start a hand."));
133 }
134
125 const actions = node("div", "actions"); 135 const actions = node("div", "actions");
126 if (playerPhase) { 136 if (playerPhase) {
127 const actionsRow = node("div", "row wrap"); 137 const actionsRow = node("div", "row wrap");
diff --git a/web/play/src/crash.ts b/web/play/src/crash.ts
index a683dcd..0f18bd2 100644
--- a/web/play/src/crash.ts
+++ b/web/play/src/crash.ts
@@ -44,26 +44,22 @@ function build(
44 sendCommand = send; 44 sendCommand = send;
45 clock = now; 45 clock = now;
46 46
47 board.append(node("h2", undefined, "Crash"));
48 board.append(
49 node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."),
50 );
51
52 const wrap = node("div", "crash-wrap"); 47 const wrap = node("div", "crash-wrap");
53 const number = node("div", "crash-number", "1.00×"); 48 const number = node("div", "crash-number", "1.00×");
49 const phase = node("div", "label", "READY");
54 const meta = node("div", "crash-meta"); 50 const meta = node("div", "crash-meta");
55 const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; 51 const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement;
56 const verify = node("div", "verify", "Waiting for a round commitment."); 52 const verify = node("div", "verify", "Waiting for a round commitment.");
57 wrap.append(number, meta, canvas, verify); 53 wrap.append(number, phase, meta, canvas, verify);
58 board.append(wrap); 54 board.append(wrap);
59 55
60 side.append(node("div", "label", "Crash")); 56 side.append(node("h2", undefined, "Crash"));
61 side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); 57 side.append(node("p", "note", "Cash out before the multiplier crashes."));
62 58
63 const betRow = node("div", "row"); 59 const betRow = node("div", "row");
64 const minus = node("button", "btn small", "−10") as HTMLButtonElement; 60 const minus = node("button", "btn small", "−") as HTMLButtonElement;
65 const bet = node("strong", "mono", "—"); 61 const bet = node("strong", "mono", "—");
66 const plus = node("button", "btn small", "+10") as HTMLButtonElement; 62 const plus = node("button", "btn small", "+") as HTMLButtonElement;
67 minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); 63 minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 }));
68 plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); 64 plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 }));
69 betRow.append(minus, bet, plus); 65 betRow.append(minus, bet, plus);
@@ -80,7 +76,7 @@ function build(
80 side.append(row); 76 side.append(row);
81 } 77 }
82 78
83 const status = node("p", "note", "Ready."); 79 const status = node("p", "note", "Press START to play.");
84 const lastCrash = node("p", "note", ""); 80 const lastCrash = node("p", "note", "");
85 const error = node("p", "error-text"); 81 const error = node("p", "error-text");
86 side.append(status, lastCrash, error); 82 side.append(status, lastCrash, error);
@@ -188,7 +184,7 @@ export function renderCrash(
188 p.status.textContent = "Round forfeited."; 184 p.status.textContent = "Round forfeited.";
189 p.status.className = "note error-text"; 185 p.status.className = "note error-text";
190 } else { 186 } else {
191 p.status.textContent = "Ready. Start a round when you want."; 187 p.status.textContent = "Press START to play.";
192 p.status.className = "note"; 188 p.status.className = "note";
193 } 189 }
194 190
@@ -197,7 +193,9 @@ export function renderCrash(
197 } 193 }
198 194
199 const commit = view.commit ? String(view.commit) : ""; 195 const commit = view.commit ? String(view.commit) : "";
200 p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; 196 p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "";
197 p.meta.style.display = commit ? "" : "none";
198 p.verify.style.display = commit ? "" : "none";
201 199
202 if (view.seed && commit) { 200 if (view.seed && commit) {
203 const token = ++verifyToken; 201 const token = ++verifyToken;
@@ -249,6 +247,19 @@ function currentMultiplier(view: View, now: Clock): number {
249function update(p: Parts, view: View, now: Clock): void { 247function update(p: Parts, view: View, now: Clock): void {
250 const multiplier = currentMultiplier(view, now); 248 const multiplier = currentMultiplier(view, now);
251 p.number.textContent = `${multiplier.toFixed(2)}×`; 249 p.number.textContent = `${multiplier.toFixed(2)}×`;
250 const phase = p.number.nextElementSibling as HTMLElement | null;
251 if (phase) {
252 phase.textContent =
253 view.status === "running"
254 ? "RUNNING"
255 : view.status === "cashed"
256 ? "CASHED OUT"
257 : view.status === "crashed"
258 ? "CRASHED"
259 : view.status === "starting"
260 ? "STARTING"
261 : "READY";
262 }
252 p.number.className = `crash-number ${ 263 p.number.className = `crash-number ${
253 view.status === "running" 264 view.status === "running"
254 ? "running" 265 ? "running"
@@ -296,6 +307,8 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no
296 ctx.strokeStyle = "#242e38"; 307 ctx.strokeStyle = "#242e38";
297 ctx.fillStyle = "#8a98a6"; 308 ctx.fillStyle = "#8a98a6";
298 ctx.font = "11px ui-monospace, monospace"; 309 ctx.font = "11px ui-monospace, monospace";
310 ctx.textAlign = "left";
311 ctx.textBaseline = "middle";
299 ctx.lineWidth = 1; 312 ctx.lineWidth = 1;
300 for (let step = 0; step <= 4; step += 1) { 313 for (let step = 0; step <= 4; step += 1) {
301 const value = 1 + ((yMax - 1) * step) / 4; 314 const value = 1 + ((yMax - 1) * step) / 4;
@@ -304,7 +317,7 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no
304 ctx.moveTo(padL, py); 317 ctx.moveTo(padL, py);
305 ctx.lineTo(width - padR, py); 318 ctx.lineTo(width - padR, py);
306 ctx.stroke(); 319 ctx.stroke();
307 ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); 320 ctx.fillText(`${value.toFixed(1)}×`, 10, py);
308 } 321 }
309 for (let step = 0; step <= 4; step += 1) { 322 for (let step = 0; step <= 4; step += 1) {
310 const seconds = (xMax * step) / 4; 323 const seconds = (xMax * step) / 4;
@@ -315,7 +328,20 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no
315 ctx.strokeStyle = "#1b232c"; 328 ctx.strokeStyle = "#1b232c";
316 ctx.stroke(); 329 ctx.stroke();
317 ctx.fillStyle = "#5b6773"; 330 ctx.fillStyle = "#5b6773";
318 ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); 331 ctx.textAlign = "center";
332 ctx.textBaseline = "alphabetic";
333 ctx.fillText(`${seconds.toFixed(0)}s`, px, height - 12);
334 ctx.textAlign = "left";
335 ctx.textBaseline = "middle";
336 }
337
338 if (!["running", "cashed", "crashed"].includes(String(view.status))) {
339 ctx.fillStyle = "#5b6773";
340 ctx.textAlign = "center";
341 ctx.textBaseline = "middle";
342 ctx.font = '13px "Open Sans", sans-serif';
343 ctx.fillText("Press START to play", padL + plotW / 2, padT + plotH / 2);
344 return;
319 } 345 }
320 346
321 const line = 347 const line =
diff --git a/web/play/src/history.ts b/web/play/src/history.ts
index 9e7a564..943c8b8 100644
--- a/web/play/src/history.ts
+++ b/web/play/src/history.ts
@@ -1,5 +1,5 @@
1import type { View } from "./types"; 1import type { View } from "./types";
2import { clear, formatTime, money, node, signedMoney } from "./dom"; 2import { clear, money, node } from "./dom";
3 3
4function summary(record: any): string { 4function summary(record: any): string {
5 const details = record.details ?? {}; 5 const details = record.details ?? {};
@@ -37,21 +37,20 @@ export function renderHistory(root: HTMLElement, view: View): void {
37 const table = node("table"); 37 const table = node("table");
38 const head = node("thead"); 38 const head = node("thead");
39 const headRow = node("tr"); 39 const headRow = node("tr");
40 for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) { 40 for (const label of ["When", "Game", "Bet", "Won", "Balance", "Result"]) {
41 headRow.append(node("th", undefined, label)); 41 headRow.append(node("th", undefined, label));
42 } 42 }
43 head.append(headRow); 43 head.append(headRow);
44 const body = node("tbody"); 44 const body = node("tbody");
45 for (const record of records) { 45 for (const record of records) {
46 const row = node("tr"); 46 const row = node("tr");
47 row.append(node("td", "mono", formatTime(String(record.at ?? "")))); 47 row.append(node("td", "mono", String(record.at ?? "").slice(0, 16)));
48 row.append(node("td", undefined, String(record.game ?? ""))); 48 row.append(node("td", "mono", String(record.game ?? "").toUpperCase()));
49 row.append(node("td", "num", money(Number(record.bet ?? 0)))); 49 row.append(node("td", "num", money(Number(record.bet ?? 0))));
50 row.append(node("td", "muted", summary(record))); 50 const winnings = Number(record.winnings ?? 0);
51 const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0); 51 row.append(node("td", `num ${winnings > 0 ? "pos" : ""}`, money(winnings)));
52 const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net));
53 row.append(netCell);
54 row.append(node("td", "num", money(Number(record.balance_after ?? 0)))); 52 row.append(node("td", "num", money(Number(record.balance_after ?? 0))));
53 row.append(node("td", "muted", summary(record)));
55 body.append(row); 54 body.append(row);
56 } 55 }
57 table.append(head, body); 56 table.append(head, body);
diff --git a/web/play/src/main.ts b/web/play/src/main.ts
index 690d827..2ca4ab5 100644
--- a/web/play/src/main.ts
+++ b/web/play/src/main.ts
@@ -17,17 +17,18 @@ const accountBalance = byId("account-balance");
17const gameGrid = byId("game-grid"); 17const gameGrid = byId("game-grid");
18const gameBoard = byId("game-board"); 18const gameBoard = byId("game-board");
19const gameSide = byId("game-side"); 19const gameSide = byId("game-side");
20const gameTitle = byId("game-title"); 20const versionPill = byId("version-pill");
21const gameBadge = byId("game-badge"); 21const accountNumber = byId("account-number");
22const lobbyAccount = byId("lobby-account"); 22const lobbyCount = byId("lobby-count");
23
23const historyBody = byId("history-body"); 24const historyBody = byId("history-body");
24const authForm = byId<HTMLFormElement>("auth-form"); 25const authForm = byId<HTMLFormElement>("auth-form");
25const authUser = byId<HTMLInputElement>("auth-user"); 26const authUser = byId<HTMLInputElement>("auth-user");
26const authPass = byId<HTMLInputElement>("auth-pass"); 27const authPass = byId<HTMLInputElement>("auth-pass");
27const authError = byId("auth-error"); 28const authError = byId("auth-error");
28const authSubmit = byId<HTMLButtonElement>("auth-submit"); 29const authSubmit = byId<HTMLButtonElement>("auth-submit");
29const tabLogin = byId<HTMLButtonElement>("tab-login"); 30const authSwitch = byId<HTMLButtonElement>("auth-switch");
30const tabRegister = byId<HTMLButtonElement>("tab-register"); 31const authTitle = byId("auth-title");
31const reconnectOverlay = byId("reconnect-overlay"); 32const reconnectOverlay = byId("reconnect-overlay");
32const reconnectMessage = byId("reconnect-message"); 33const reconnectMessage = byId("reconnect-message");
33const replacedOverlay = byId("replaced-overlay"); 34const replacedOverlay = byId("replaced-overlay");
@@ -39,6 +40,7 @@ let authMode: "login" | "register" = "login";
39let infoFor: string | null = null; 40let infoFor: string | null = null;
40let serverOffset = 0; 41let serverOffset = 0;
41let reconnectAttempts = 0; 42let reconnectAttempts = 0;
43let lastScreen: string | null = null;
42const bets = new Map<string, number>([ 44const bets = new Map<string, number>([
43 ["slots", 10], 45 ["slots", 10],
44 ["roulette", 10], 46 ["roulette", 10],
@@ -54,10 +56,10 @@ const titles: Record<string, string> = {
54}; 56};
55 57
56const descriptions: Record<string, string> = { 58const descriptions: Record<string, string> = {
57 slots: "Five reels, five paylines. Match symbols from the left.", 59 slots: "Spin the reels. Three or more matching symbols from the left win.",
58 roulette: "European single-zero wheel with inside and outside bets.", 60 roulette: "Put chips on the table, then spin. Straight numbers pay the most.",
59 blackjack: "Six-deck shoe. Blackjack pays 3:2.", 61 blackjack: "Get closer to 21 than the dealer without going over.",
60 crash: "A rising multiplier. Cash out before it crashes.", 62 crash: "Start the round, then cash out before the multiplier crashes.",
61}; 63};
62 64
63const basePath = location.pathname.includes("/play/") 65const basePath = location.pathname.includes("/play/")
@@ -83,9 +85,13 @@ const gateway = new Gateway(wsUrl, {
83 serverOffset = hello.time - Date.now(); 85 serverOffset = hello.time - Date.now();
84 currentUser = hello.user ?? null; 86 currentUser = hello.user ?? null;
85 if (typeof hello.balance === "number") lastBalance = hello.balance; 87 if (typeof hello.balance === "number") lastBalance = hello.balance;
86 gameBadge.textContent = `v${hello.version}`; 88 versionPill.textContent = `v${hello.version}`;
87 }, 89 },
88 onView(next) { 90 onView(next) {
91 if (next.screen !== lastScreen) {
92 byId("toasts").replaceChildren();
93 lastScreen = next.screen;
94 }
89 view = next; 95 view = next;
90 if (next.screen !== "slots") stopSlotsAnimation(); 96 if (next.screen !== "slots") stopSlotsAnimation();
91 if (next.screen !== "crash") stopCrashAnimation(); 97 if (next.screen !== "crash") stopCrashAnimation();
@@ -124,12 +130,12 @@ const gateway = new Gateway(wsUrl, {
124 authUser.focus(); 130 authUser.focus();
125 } 131 }
126 authSubmit.disabled = false; 132 authSubmit.disabled = false;
127 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; 133 authSubmit.textContent = authMode === "login" ? "LOG IN" : "CREATE ACCOUNT";
128 }, 134 },
129 onError(text) { 135 onError(text) {
130 toast(text, "bad"); 136 toast(text, "bad");
131 authSubmit.disabled = false; 137 authSubmit.disabled = false;
132 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; 138 authSubmit.textContent = authMode === "login" ? "LOG IN" : "CREATE ACCOUNT";
133 }, 139 },
134 onReplaced() { 140 onReplaced() {
135 replacedOverlay.hidden = false; 141 replacedOverlay.hidden = false;
@@ -166,9 +172,9 @@ function serverNow(): number {
166 172
167function setAuthMode(mode: "login" | "register"): void { 173function setAuthMode(mode: "login" | "register"): void {
168 authMode = mode; 174 authMode = mode;
169 tabLogin.classList.toggle("active", mode === "login"); 175 authTitle.textContent = mode === "login" ? "Sign in" : "Create account";
170 tabRegister.classList.toggle("active", mode === "register"); 176 authSubmit.textContent = mode === "login" ? "LOG IN" : "CREATE ACCOUNT";
171 authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; 177 authSwitch.textContent = mode === "login" ? "CREATE ACCOUNT" : "BACK TO SIGN IN";
172 authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; 178 authPass.autocomplete = mode === "login" ? "current-password" : "new-password";
173 authError.textContent = ""; 179 authError.textContent = "";
174} 180}
@@ -192,6 +198,7 @@ function render(): void {
192 accountName.textContent = String(user); 198 accountName.textContent = String(user);
193 } else { 199 } else {
194 sessionBar.hidden = true; 200 sessionBar.hidden = true;
201 accountNumber.textContent = "—";
195 } 202 }
196 203
197 setBalance(lastBalance); 204 setBalance(lastBalance);
@@ -219,8 +226,6 @@ function render(): void {
219 case "blackjack": 226 case "blackjack":
220 case "crash": 227 case "crash":
221 show("game"); 228 show("game");
222 gameTitle.textContent = titles[view.screen] ?? view.screen;
223 gameBadge.textContent = badge(view);
224 if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); 229 if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send);
225 else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); 230 else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send);
226 else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); 231 else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send);
@@ -231,47 +236,23 @@ function render(): void {
231 } 236 }
232} 237}
233 238
234function badge(view: View): string {
235 if (view.screen === "crash") return String(view.status ?? "");
236 if (view.screen === "blackjack") return String(view.status ?? "");
237 if (view.screen === "slots") return String(view.phase ?? "");
238 if (view.screen === "roulette") return String(view.phase ?? "");
239 return "";
240}
241
242function renderLobby(lobby: View): void { 239function renderLobby(lobby: View): void {
243 lobbyAccount.textContent = lobby.account ? `Account ${lobby.account}` : ""; 240 accountNumber.textContent = lobby.account ? String(lobby.account) : "—";
241 const order = ["slots", "roulette", "blackjack", "crash"];
242 const list: [string, string][] = [...(lobby.games ?? [])].sort(
243 (a, b) => order.indexOf(a[0]) - order.indexOf(b[0]),
244 );
245 lobbyCount.textContent = `${list.length} ${list.length === 1 ? "game" : "games"}`;
244 gameGrid.replaceChildren(); 246 gameGrid.replaceChildren();
245 const list: [string, string][] = lobby.games ?? [];
246 for (const [id, name] of list) { 247 for (const [id, name] of list) {
247 const card = node("article", `game-card ${id}`); 248 const card = node("article", `game-card ${id}`);
248 card.append(node("h3", undefined, name || titles[id] || id)); 249 card.append(node("h3", undefined, name || titles[id] || id));
249 card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); 250 card.append(node("p", undefined, descriptions[id] ?? "Play a round."));
250 251 const play = node("button", "btn primary wide", "PLAY");
251 const row = node("div", "row wrap");
252 const label = node("label", "field");
253 label.style.flex = "1";
254 label.append(node("span", "label", "Bet"));
255 const input = node("input", "input bet-input") as HTMLInputElement;
256 input.type = "number";
257 input.min = "1";
258 input.step = "10";
259 input.value = String(bets.get(id) ?? 10);
260 input.addEventListener("change", () => {
261 const value = Math.max(1, Math.floor(Number(input.value) || 1));
262 input.value = String(value);
263 bets.set(id, value);
264 });
265 label.append(input);
266
267 const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play");
268 play.addEventListener("click", () => { 252 play.addEventListener("click", () => {
269 const bet = Math.max(1, Math.floor(Number(input.value) || 1)); 253 send({ Play: { game: id, bet: bets.get(id) ?? 10 } });
270 bets.set(id, bet);
271 send({ Play: { game: id, bet } });
272 }); 254 });
273 row.append(label, play); 255 card.append(play);
274 card.append(row);
275 gameGrid.append(card); 256 gameGrid.append(card);
276 } 257 }
277 258
@@ -280,9 +261,10 @@ function renderLobby(lobby: View): void {
280 } 261 }
281} 262}
282 263
283// Auth tabs and form ------------------------------------------------------- 264// Auth mode and form -------------------------------------------------------
284tabLogin.addEventListener("click", () => setAuthMode("login")); 265authSwitch.addEventListener("click", () =>
285tabRegister.addEventListener("click", () => setAuthMode("register")); 266 setAuthMode(authMode === "login" ? "register" : "login"),
267);
286 268
287authForm.addEventListener("submit", async (event) => { 269authForm.addEventListener("submit", async (event) => {
288 event.preventDefault(); 270 event.preventDefault();
@@ -312,16 +294,7 @@ authForm.addEventListener("submit", async (event) => {
312// Chrome and navigation ---------------------------------------------------- 294// Chrome and navigation ----------------------------------------------------
313byId("logout").addEventListener("click", () => send("Logout")); 295byId("logout").addEventListener("click", () => send("Logout"));
314byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); 296byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } }));
315byId("refresh-lobby").addEventListener("click", () => send("Info"));
316byId("history-back").addEventListener("click", () => send("ShowLobby")); 297byId("history-back").addEventListener("click", () => send("ShowLobby"));
317byId("leave-game").addEventListener("click", () => {
318 if (!view) return;
319 const live =
320 (view.screen === "blackjack" && view.status === "player") ||
321 (view.screen === "crash" && view.status === "running");
322 if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return;
323 send("Leave");
324});
325byId("reload-replaced").addEventListener("click", () => location.reload()); 298byId("reload-replaced").addEventListener("click", () => location.reload());
326byId("reload-reconnect").addEventListener("click", () => location.reload()); 299byId("reload-reconnect").addEventListener("click", () => location.reload());
327 300
diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts
index a42686a..0493d04 100644
--- a/web/play/src/roulette.ts
+++ b/web/play/src/roulette.ts
@@ -20,12 +20,10 @@ type Hotspot = {
20 rank: number; 20 rank: number;
21}; 21};
22 22
23let observer: ResizeObserver | null = null;
24let lastSpin = 0;
25
26type Layout = { 23type Layout = {
27 width: number; 24 width: number;
28 height: number; 25 height: number;
26 pad: number;
29 cellW: number; 27 cellW: number;
30 cellH: number; 28 cellH: number;
31 zeroX: number; 29 zeroX: number;
@@ -33,42 +31,50 @@ type Layout = {
33 gridX: number; 31 gridX: number;
34 gridY: number; 32 gridY: number;
35 gridW: number; 33 gridW: number;
36 columnX: number;
37 dozensY: number; 34 dozensY: number;
35 columnsY: number;
38 outsideY: number; 36 outsideY: number;
39 outsideH: number; 37 outsideH: number;
40 zones: Zone[]; 38 zones: Zone[];
41 hotspots: Hotspot[]; 39 hotspots: Hotspot[];
42}; 40};
43 41
42const WHEEL = [
43 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5,
44 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26,
45];
46const RED_NUMBERS = new Set([
47 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36,
48]);
49
44const RED = "#b0313a"; 50const RED = "#b0313a";
45const BLACK = "#151b21"; 51const BLACK = "#151b21";
46const ZERO = "#106b45"; 52const ZERO = "#106b45";
47const FELT = "#0d5c39"; 53const FELT = "#0d5c39";
48const FELT_DARK = "#09472c";
49const FELT_LINE = "#1f7d52"; 54const FELT_LINE = "#1f7d52";
50const TEXT = "#ecf7f0"; 55const TEXT = "#ecf7f0";
51const GOLD = "#e0b64a"; 56const GOLD = "#e0b64a";
57const SURFACE = "#11171d";
52 58
53function colorOf(number: number): string { 59let observer: ResizeObserver | null = null;
60
61function colorOf(number: number): "red" | "black" | "green" {
54 if (number === 0) return "green"; 62 if (number === 0) return "green";
55 const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; 63 return RED_NUMBERS.has(number) ? "red" : "black";
56 return reds.includes(number) ? "red" : "black";
57} 64}
58 65
59function layout(width: number, height: number): Layout { 66function layout(width: number, height: number): Layout {
60 const pad = 14; 67 const pad = 14;
61 const zeroW = Math.max(38, width * 0.055); 68 const zeroW = Math.max(44, width * 0.058);
62 const columnW = Math.max(34, width * 0.05); 69 const outsideH = Math.max(28, Math.min(38, height * 0.075));
63 const outsideH = Math.max(30, height * 0.07); 70 const gridW = width - pad * 2 - zeroW;
64 const gap = 6;
65 const gridW = width - pad * 2 - zeroW - columnW;
66 const cellW = gridW / 12; 71 const cellW = gridW / 12;
67 const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; 72 const cellH = (height - pad * 2 - outsideH * 3) / 3;
68 const gridX = pad + zeroW; 73 const gridX = pad + zeroW;
69 const gridY = pad; 74 const gridY = pad;
70 const dozensY = gridY + cellH * 3 + gap; 75 const dozensY = gridY + cellH * 3;
71 const outsideY = dozensY + outsideH + gap; 76 const columnsY = dozensY + outsideH;
77 const outsideY = columnsY + outsideH;
72 const zones: Zone[] = []; 78 const zones: Zone[] = [];
73 79
74 for (let column = 0; column < 12; column += 1) { 80 for (let column = 0; column < 12; column += 1) {
@@ -98,19 +104,6 @@ function layout(width: number, height: number): Layout {
98 number: 0, 104 number: 0,
99 }); 105 });
100 106
101 for (let top = 0; top < 3; top += 1) {
102 const index = 2 - top;
103 zones.push({
104 x: gridX + gridW,
105 y: gridY + top * cellH,
106 w: columnW,
107 h: cellH,
108 target: { bet: "column", index },
109 label: "2:1",
110 kind: "column",
111 });
112 }
113
114 for (let index = 0; index < 3; index += 1) { 107 for (let index = 0; index < 3; index += 1) {
115 zones.push({ 108 zones.push({
116 x: gridX + (gridW / 3) * index, 109 x: gridX + (gridW / 3) * index,
@@ -121,6 +114,15 @@ function layout(width: number, height: number): Layout {
121 label: ["1st 12", "2nd 12", "3rd 12"][index], 114 label: ["1st 12", "2nd 12", "3rd 12"][index],
122 kind: "dozen", 115 kind: "dozen",
123 }); 116 });
117 zones.push({
118 x: gridX + (gridW / 3) * index,
119 y: columnsY,
120 w: gridW / 3,
121 h: outsideH,
122 target: { bet: "column", index: 2 - index },
123 label: "2:1",
124 kind: "column",
125 });
124 } 126 }
125 127
126 const outside = [ 128 const outside = [
@@ -143,8 +145,6 @@ function layout(width: number, height: number): Layout {
143 }); 145 });
144 }); 146 });
145 147
146 // Split and corner hotspots sit on the lines between cells, like the real
147 // felt (and the desktop fine lattice). Corners rank ahead of splits.
148 const hotspots: Hotspot[] = []; 148 const hotspots: Hotspot[] = [];
149 const cellCenter = (number: number) => { 149 const cellCenter = (number: number) => {
150 const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); 150 const cell = zones.find((zone) => zone.kind === "number" && zone.number === number);
@@ -193,19 +193,19 @@ function layout(width: number, height: number): Layout {
193 } 193 }
194 194
195 for (let row = 0; row < 3; row += 1) { 195 for (let row = 0; row < 3; row += 1) {
196 const number = 3 - row;
197 hotspots.push({ 196 hotspots.push({
198 x: gridX, 197 x: gridX,
199 y: gridY + row * cellH + cellH / 2, 198 y: gridY + row * cellH + cellH / 2,
200 radius: 10, 199 radius: 10,
201 rank: 1, 200 rank: 1,
202 target: { bet: "split", a: 0, b: number }, 201 target: { bet: "split", a: 0, b: 3 - row },
203 }); 202 });
204 } 203 }
205 204
206 return { 205 return {
207 width, 206 width,
208 height, 207 height,
208 pad,
209 cellW, 209 cellW,
210 cellH, 210 cellH,
211 zeroX: pad, 211 zeroX: pad,
@@ -213,8 +213,8 @@ function layout(width: number, height: number): Layout {
213 gridX, 213 gridX,
214 gridY, 214 gridY,
215 gridW, 215 gridW,
216 columnX: gridX + gridW,
217 dozensY, 216 dozensY,
217 columnsY,
218 outsideY, 218 outsideY,
219 outsideH, 219 outsideH,
220 zones, 220 zones,
@@ -222,27 +222,21 @@ function layout(width: number, height: number): Layout {
222 }; 222 };
223} 223}
224 224
225function roundRect( 225function rect(
226 ctx: CanvasRenderingContext2D, 226 ctx: CanvasRenderingContext2D,
227 x: number, 227 x: number,
228 y: number, 228 y: number,
229 w: number, 229 w: number,
230 h: number, 230 h: number,
231 radius: number,
232): void { 231): void {
233 const r = Math.min(radius, w / 2, h / 2);
234 ctx.beginPath(); 232 ctx.beginPath();
235 ctx.moveTo(x + r, y); 233 ctx.rect(x, y, w, h);
236 ctx.arcTo(x + w, y, x + w, y + h, r);
237 ctx.arcTo(x + w, y + h, x, y + h, r);
238 ctx.arcTo(x, y + h, x, y, r);
239 ctx.arcTo(x, y, x + w, y, r);
240 ctx.closePath(); 234 ctx.closePath();
241} 235}
242 236
243function draw(canvas: HTMLCanvasElement, view: View): Layout { 237function drawTable(canvas: HTMLCanvasElement, view: View): Layout {
244 const width = canvas.clientWidth || 800; 238 const width = canvas.clientWidth || 900;
245 const height = canvas.clientHeight || 540; 239 const height = canvas.clientHeight || 500;
246 const dpr = window.devicePixelRatio || 1; 240 const dpr = window.devicePixelRatio || 1;
247 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { 241 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
248 canvas.width = Math.round(width * dpr); 242 canvas.width = Math.round(width * dpr);
@@ -253,18 +247,23 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout {
253 if (!ctx) return map; 247 if (!ctx) return map;
254 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 248 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
255 ctx.clearRect(0, 0, width, height); 249 ctx.clearRect(0, 0, width, height);
256 ctx.fillStyle = FELT_DARK; 250
257 roundRect(ctx, 0, 0, width, height, 10); 251 // The whole felt is green; cells sit on it with thin lines, like the desktop.
252 ctx.fillStyle = FELT;
253 rect(ctx, map.pad, map.gridY, map.zeroW + map.gridW, map.cellH * 3 + map.outsideH * 3);
258 ctx.fill(); 254 ctx.fill();
259 255
260 for (const zone of map.zones) { 256 for (const zone of map.zones) {
261 let fill = FELT; 257 let fill = FELT;
262 if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; 258 if (zone.kind === "number") {
263 else if (zone.kind === "zero") fill = ZERO; 259 fill = colorOf(zone.number!) === "red" ? RED : BLACK;
260 } else if (zone.kind === "zero") {
261 fill = ZERO;
262 }
264 ctx.fillStyle = fill; 263 ctx.fillStyle = fill;
265 ctx.strokeStyle = FELT_LINE; 264 ctx.strokeStyle = FELT_LINE;
266 ctx.lineWidth = 1; 265 ctx.lineWidth = 1;
267 roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); 266 rect(ctx, zone.x + 0.5, zone.y + 0.5, zone.w - 1, zone.h - 1);
268 ctx.fill(); 267 ctx.fill();
269 ctx.stroke(); 268 ctx.stroke();
270 269
@@ -272,10 +271,12 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout {
272 ctx.textAlign = "center"; 271 ctx.textAlign = "center";
273 ctx.textBaseline = "middle"; 272 ctx.textBaseline = "middle";
274 if (zone.kind === "number" || zone.kind === "zero") { 273 if (zone.kind === "number" || zone.kind === "zero") {
275 ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; 274 ctx.font = `500 ${Math.min(18, map.cellH * 0.16)}px "Open Sans", sans-serif`;
276 } else { 275 } else {
277 ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; 276 ctx.font = `500 ${Math.min(12, map.outsideH * 0.4)}px "Open Sans", sans-serif`;
278 } 277 }
278 if (zone.label === "RED") ctx.fillStyle = "#e05a5a";
279 if (zone.label === "BLACK") ctx.fillStyle = "#8a98a6";
279 ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); 280 ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2);
280 } 281 }
281 282
@@ -292,12 +293,10 @@ function centerFor(layout: Layout, target: any): { x: number; y: number } | null
292 const exact = layout.zones.find((zone) => targetKey(zone.target) === key); 293 const exact = layout.zones.find((zone) => targetKey(zone.target) === key);
293 if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; 294 if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 };
294 295
295 // Split and corner bets are reachable from the desktop's fine lattice. A
296 // browser session can still show them: average the covered number cells.
297 if (target?.bet === "split" || target?.bet === "corner") { 296 if (target?.bet === "split" || target?.bet === "corner") {
298 const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; 297 const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers;
299 const cells = layout.zones.filter( 298 const cells = layout.zones.filter(
300 (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), 299 (zone) => zone.kind === "number" && numbers.includes(zone.number!),
301 ); 300 );
302 if (cells.length > 0) { 301 if (cells.length > 0) {
303 return { 302 return {
@@ -314,7 +313,7 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void
314 for (const chip of chips) { 313 for (const chip of chips) {
315 const center = centerFor(map, chip.target); 314 const center = centerFor(map, chip.target);
316 if (!center) continue; 315 if (!center) continue;
317 const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); 316 const radius = Math.max(12, Math.min(17, map.cellW * 0.28));
318 ctx.beginPath(); 317 ctx.beginPath();
319 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); 318 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
320 ctx.fillStyle = GOLD; 319 ctx.fillStyle = GOLD;
@@ -325,11 +324,91 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void
325 ctx.fillStyle = "#221a05"; 324 ctx.fillStyle = "#221a05";
326 ctx.textAlign = "center"; 325 ctx.textAlign = "center";
327 ctx.textBaseline = "middle"; 326 ctx.textBaseline = "middle";
328 ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; 327 ctx.font = `700 ${radius > 15 ? 11 : 10}px "Hack", monospace`;
329 ctx.fillText(String(chip.amount), center.x, center.y + 0.5); 328 ctx.fillText(String(chip.amount), center.x, center.y + 0.5);
330 } 329 }
331} 330}
332 331
332function drawWheel(canvas: HTMLCanvasElement, view: View): void {
333 const size = 170;
334 const dpr = window.devicePixelRatio || 1;
335 canvas.width = Math.round(size * dpr);
336 canvas.height = Math.round(size * dpr);
337 canvas.style.width = `${size}px`;
338 canvas.style.height = `${size}px`;
339 const ctx = canvas.getContext("2d");
340 if (!ctx) return;
341 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
342
343 const result = view.phase === "result" && view.number !== null && view.number !== undefined
344 ? Number(view.number)
345 : null;
346 const targetIndex = result === null ? -1 : WHEEL.indexOf(result);
347 const step = (Math.PI * 2) / WHEEL.length;
348 const targetRotation = targetIndex < 0 ? 0 : -targetIndex * step;
349 const start = performance.now();
350 const duration = 650;
351
352 const paint = (rotation: number) => {
353 const cx = size / 2;
354 const cy = size / 2;
355 const outer = size * 0.46;
356 const inner = size * 0.29;
357 ctx.clearRect(0, 0, size, size);
358
359 ctx.fillStyle = SURFACE;
360 ctx.beginPath();
361 ctx.arc(cx, cy, outer + 6, 0, Math.PI * 2);
362 ctx.fill();
363
364 WHEEL.forEach((number, index) => {
365 const angle = -Math.PI / 2 + index * step + rotation;
366 ctx.beginPath();
367 ctx.moveTo(cx, cy);
368 ctx.arc(cx, cy, outer, angle - step / 2, angle + step / 2);
369 ctx.closePath();
370 ctx.fillStyle = colorOf(number) === "red" ? RED : colorOf(number) === "green" ? ZERO : BLACK;
371 ctx.fill();
372 ctx.strokeStyle = FELT_LINE;
373 ctx.lineWidth = 0.8;
374 ctx.stroke();
375 });
376
377 ctx.beginPath();
378 ctx.arc(cx, cy, inner, 0, Math.PI * 2);
379 ctx.fillStyle = "#0a0e12";
380 ctx.fill();
381 ctx.strokeStyle = FELT_LINE;
382 ctx.lineWidth = 1;
383 ctx.stroke();
384
385 // Pointer at twelve o'clock.
386 ctx.beginPath();
387 ctx.moveTo(cx, 8);
388 ctx.lineTo(cx - 5, 18);
389 ctx.lineTo(cx + 5, 18);
390 ctx.closePath();
391 ctx.fillStyle = TEXT;
392 ctx.fill();
393
394 if (result !== null) {
395 ctx.fillStyle = TEXT;
396 ctx.textAlign = "center";
397 ctx.textBaseline = "middle";
398 ctx.font = '600 15px "Hack", monospace';
399 ctx.fillText(String(result), cx, cy);
400 }
401 };
402
403 const animate = (now: number) => {
404 const progress = Math.min(1, (now - start) / duration);
405 const eased = 1 - (1 - progress) ** 3;
406 paint(targetRotation * eased);
407 if (progress < 1) requestAnimationFrame(animate);
408 };
409 requestAnimationFrame(animate);
410}
411
333export function renderRoulette( 412export function renderRoulette(
334 board: HTMLElement, 413 board: HTMLElement,
335 side: HTMLElement, 414 side: HTMLElement,
@@ -340,43 +419,44 @@ export function renderRoulette(
340 clear(side); 419 clear(side);
341 420
342 const spinning = view.phase === "spinning"; 421 const spinning = view.phase === "spinning";
343 board.append(node("h2", undefined, "Roulette"));
344 board.append(
345 node(
346 "p",
347 "note",
348 "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.",
349 ),
350 );
351
352 const wrap = node("div", "roulette-wrap");
353 const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; 422 const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement;
354 wrap.append(canvas); 423 board.append(canvas);
355 424
425 const result = node("div", "board-foot tight");
426 result.append(node("span", "label", "Last"));
356 if (view.phase === "result" && view.number !== null && view.number !== undefined) { 427 if (view.phase === "result" && view.number !== null && view.number !== undefined) {
357 const result = node("div", `wheel-result ${view.color ?? ""}`); 428 const value = node(
358 result.append(node("div", "label", "Result")); 429 "span",
359 result.append(node("div", "num", String(view.number))); 430 view.winnings && Number(view.winnings) > 0 ? "win-text" : undefined,
360 const winnings = Number(view.winnings ?? 0); 431 `${view.number} ${view.color} · ${Number(view.winnings ?? 0) > 0 ? money(Number(view.winnings)) : "no win"}`,
361 result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); 432 );
362 wrap.append(result); 433 result.append(value);
434 } else {
435 result.append(node("span", undefined, "No spins yet"));
363 } 436 }
364 board.append(wrap); 437 result.append(
438 node("span", "note", "Click to place a chip · right-click to take one back"),
439 );
440 board.append(result);
365 441
366 // The canvas must be in the document before its client size is read. 442 const map = drawTable(canvas, view);
367 const map = draw(canvas, view);
368 let current = map; 443 let current = map;
369 444
370 const hit = (clientX: number, clientY: number): Zone | null => { 445 const hit = (clientX: number, clientY: number): Zone | null => {
371 const rect = canvas.getBoundingClientRect(); 446 const bounds = canvas.getBoundingClientRect();
372 const x = clientX - rect.left; 447 const x = clientX - bounds.left;
373 const y = clientY - rect.top; 448 const y = clientY - bounds.top;
374 449
375 let best: Hotspot | null = null; 450 let best: Hotspot | null = null;
376 for (const hotspot of current.hotspots) { 451 for (const hotspot of current.hotspots) {
377 const distance = Math.hypot(x - hotspot.x, y - hotspot.y); 452 const distance = Math.hypot(x - hotspot.x, y - hotspot.y);
378 if (distance > hotspot.radius) continue; 453 if (distance > hotspot.radius) continue;
379 if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { 454 if (
455 !best ||
456 hotspot.rank < best.rank ||
457 (hotspot.rank === best.rank &&
458 distance < Math.hypot(x - best.x, y - best.y))
459 ) {
380 best = hotspot; 460 best = hotspot;
381 } 461 }
382 } 462 }
@@ -418,66 +498,105 @@ export function renderRoulette(
418 }); 498 });
419 observer?.disconnect(); 499 observer?.disconnect();
420 observer = new ResizeObserver(() => { 500 observer = new ResizeObserver(() => {
421 current = draw(canvas, view); 501 current = drawTable(canvas, view);
422 }); 502 });
423 observer.observe(canvas); 503 observer.observe(canvas);
424 504
425 // Sidebar 505 // Sidebar
426 side.append(node("div", "label", "Roulette")); 506 side.append(node("h2", undefined, "Roulette"));
427 side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); 507 side.append(node("p", "note", "Pick numbers or colours, then spin."));
508
509 const wheel = node("canvas", "wheel-canvas") as HTMLCanvasElement;
510 side.append(wheel);
511 drawWheel(wheel, view);
512
513 side.append(node("div", "label", "Place your bets"));
514 const chips = (view.bets?.chips ?? []).reduce(
515 (sum: number, chip: any) => sum + Number(chip.amount ?? 0),
516 0,
517 );
518 side.append(node("p", "note", `${chips} ${chips === 1 ? "chip" : "chips"}`));
428 519
429 side.append(node("div", "label", "Chip")); 520 side.append(node("div", "label", "Chip"));
430 const chipRow = node("div", "chip-row"); 521 const chipRow = node("div", "chip-row");
431 for (const value of [10, 25, 50, 100, 500]) { 522 for (const value of [10, 25, 50, 100]) {
432 const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); 523 const button = node(
524 "button",
525 `chip-btn ${Number(view.chip) === value ? "active" : ""}`,
526 String(value),
527 ) as HTMLButtonElement;
528 button.dataset.chip = String(value);
433 button.disabled = spinning; 529 button.disabled = spinning;
434 button.addEventListener("click", () => send({ SetChip: value })); 530 button.addEventListener("click", () => send({ SetChip: value }));
435 chipRow.append(button); 531 chipRow.append(button);
436 } 532 }
437 side.append(chipRow); 533 side.append(chipRow);
438 534
439 const total = (view.bets?.chips ?? []).reduce( 535 side.append(node("div", "label", "Bets"));
440 (sum: number, chip: any) => sum + Number(chip.amount ?? 0), 536 const bets: any[] = view.bets?.chips ?? [];
441 0, 537 if (bets.length === 0) {
442 ); 538 side.append(node("p", "note", "No chips on the felt"));
443 const totalRow = node("div", "stat"); 539 } else {
444 totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); 540 for (const chip of bets) {
445 side.append(totalRow); 541 const row = node("div", "stat");
446 542 row.append(
447 if (view.balance !== null && view.balance !== undefined) { 543 node("span", "muted", chipLabel(chip.target)),
448 const balanceRow = node("div", "stat"); 544 node("strong", "mono", money(Number(chip.amount ?? 0))),
449 balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); 545 );
450 side.append(balanceRow); 546 side.append(row);
547 }
451 } 548 }
452 549
550 const total = node("div", "stat");
551 total.append(node("span", "muted", "Total"), node("strong", "mono", money(chips)));
552 side.append(total);
553
453 if (view.error) side.append(node("p", "error-text", String(view.error))); 554 if (view.error) side.append(node("p", "error-text", String(view.error)));
454 555
455 const status = node("p", "note"); 556 const status = node("p", "note");
456 if (spinning) status.textContent = "Spinning…"; 557 if (spinning) status.textContent = "SPINNING…";
457 else if (view.phase === "result") { 558 else if (view.phase === "result") {
458 const winnings = Number(view.winnings ?? 0); 559 const winnings = Number(view.winnings ?? 0);
459 status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; 560 status.textContent = winnings > 0 ? `WON ${money(winnings)}` : "NO WIN";
460 status.className = winnings > 0 ? "note win-text" : "note"; 561 if (winnings > 0) status.classList.add("win-text");
461 } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; 562 } else status.textContent = chips > 0 ? "Ready to spin." : "Place at least one chip.";
462 side.append(status); 563 side.append(status);
463 564
464 const actions = node("div", "actions"); 565 const actions = node("div", "actions");
465 const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); 566 const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN");
466 spin.disabled = spinning || total === 0; 567 spin.disabled = spinning || chips === 0;
467 spin.addEventListener("click", () => { 568 spin.addEventListener("click", () => {
468 if (Date.now() - lastSpin < 600) return; 569 if (Date.now() - lastSpin < 600) return;
469 lastSpin = Date.now(); 570 lastSpin = Date.now();
470 send("Spin"); 571 send("Spin");
471 }); 572 });
472 const clearBets = node("button", "btn wide", "Clear bets"); 573 const clearBets = node("button", "btn wide", "CLEAR");
473 clearBets.disabled = spinning || total === 0; 574 clearBets.disabled = spinning || chips === 0;
474 clearBets.addEventListener("click", () => send("ClearBets")); 575 clearBets.addEventListener("click", () => send("ClearBets"));
475 const leave = node("button", "btn ghost wide", "Leave table"); 576 const leave = node("button", "btn ghost wide", "Leave table");
476 leave.disabled = spinning; 577 leave.disabled = spinning;
477 leave.addEventListener("click", () => { 578 leave.addEventListener("click", () => send("Leave"));
478 if (spinning) return;
479 send("Leave");
480 });
481 actions.append(spin, clearBets, leave); 579 actions.append(spin, clearBets, leave);
482 side.append(actions); 580 side.append(actions);
483} 581}
582
583let lastSpin = 0;
584
585function chipLabel(target: any): string {
586 switch (target?.bet) {
587 case "straight":
588 return String(target.number);
589 case "split":
590 return `${target.a}/${target.b}`;
591 case "corner":
592 return target.numbers.join("/");
593 case "dozen":
594 return ["1st 12", "2nd 12", "3rd 12"][target.index] ?? "dozen";
595 case "column":
596 return `col ${Number(target.index) + 1}`;
597 case "outside":
598 return String(target.kind ?? "");
599 default:
600 return "";
601 }
602}
diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts
index 8b821cf..cfc9070 100644
--- a/web/play/src/slots.ts
+++ b/web/play/src/slots.ts
@@ -49,10 +49,6 @@ export function renderSlots(
49 clear(board); 49 clear(board);
50 clear(side); 50 clear(side);
51 51
52 const title = node("h2", undefined, "Slots");
53 const note = node("p", "note", "Match three or more symbols from the left. Five paylines.");
54 board.append(title, note);
55
56 const reels = node("div", "reels"); 52 const reels = node("div", "reels");
57 const tiles: HTMLElement[] = []; 53 const tiles: HTMLElement[] = [];
58 const flat = Array.isArray(view.reels) 54 const flat = Array.isArray(view.reels)
@@ -81,24 +77,28 @@ export function renderSlots(
81 board.append(reels); 77 board.append(reels);
82 78
83 const lines = node("div", "win-lines"); 79 const lines = node("div", "win-lines");
80 let resultText = "Ready";
84 if (!active && view.phase === "result") { 81 if (!active && view.phase === "result") {
85 const winnings = Number(view.winnings ?? 0); 82 const winnings = Number(view.winnings ?? 0);
86 if (winnings > 0) { 83 if (winnings > 0) {
87 const names = (view.win_lines ?? []) 84 const names = (view.win_lines ?? [])
88 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) 85 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`)
89 .join(" · "); 86 .join(" · ");
90 lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; 87 resultText = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`;
88 lines.textContent = names;
91 lines.classList.add("win-text"); 89 lines.classList.add("win-text");
92 } else { 90 } else {
93 lines.textContent = "No win — spin again."; 91 resultText = "No win";
94 } 92 }
95 } else if (active) { 93 } else if (active) {
96 lines.textContent = "Spinning…"; 94 resultText = "Spinning…";
97 } else {
98 lines.textContent = "Pick a bet and spin.";
99 } 95 }
100 board.append(lines); 96 board.append(lines);
101 97
98 const foot = node("div", "board-foot");
99 foot.append(node("span", "label", "Result"), node("span", undefined, resultText));
100 board.append(foot);
101
102 if (active) { 102 if (active) {
103 let tick = 0; 103 let tick = 0;
104 animation = window.setInterval(() => { 104 animation = window.setInterval(() => {
@@ -117,13 +117,13 @@ export function renderSlots(
117 } 117 }
118 118
119 // Sidebar 119 // Sidebar
120 side.append(node("div", "label", "Slots")); 120 side.append(node("h2", undefined, "Slots"));
121 side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); 121 side.append(node("p", "note", "Match three or more symbols from the left."));
122 122
123 const betRow = node("div", "row"); 123 const betRow = node("div", "row");
124 const minus = node("button", "btn small", "−10"); 124 const minus = node("button", "btn small", "−");
125 const bet = node("strong", "mono", money(Number(view.bet ?? 0))); 125 const bet = node("strong", "mono", money(Number(view.bet ?? 0)));
126 const plus = node("button", "btn small", "+10"); 126 const plus = node("button", "btn small", "+");
127 minus.disabled = active; 127 minus.disabled = active;
128 plus.disabled = active; 128 plus.disabled = active;
129 minus.addEventListener("click", () => send({ AdjustBet: -10 })); 129 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
@@ -136,12 +136,12 @@ export function renderSlots(
136 } 136 }
137 137
138 const status = node("p", "note"); 138 const status = node("p", "note");
139 if (active) status.textContent = "Spinning…"; 139 if (active) status.textContent = "SPINNING…";
140 else if (view.phase === "result") { 140 else if (view.phase === "result") {
141 const winnings = Number(view.winnings ?? 0); 141 const winnings = Number(view.winnings ?? 0);
142 status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; 142 status.textContent = winnings > 0 ? `WON ${money(winnings)}` : "NO WIN";
143 if (winnings > 0) status.classList.add("win-text"); 143 if (winnings > 0) status.classList.add("win-text");
144 } else status.textContent = "Ready when you are."; 144 } else status.textContent = "Press SPIN to play.";
145 side.append(status); 145 side.append(status);
146 146
147 const actions = node("div", "actions"); 147 const actions = node("div", "actions");
diff --git a/web/play/style.css b/web/play/style.css
index e05f551..a0e4cca 100644
--- a/web/play/style.css
+++ b/web/play/style.css
@@ -78,9 +78,21 @@ button:disabled { cursor: not-allowed; opacity: 0.45; }
78 letter-spacing: 0.12em; 78 letter-spacing: 0.12em;
79} 79}
80.spacer { flex: 1; } 80.spacer { flex: 1; }
81.session { display: flex; align-items: center; gap: 12px; } 81.pill {
82.session .who { color: var(--dim); font-family: var(--mono); font-size: 12px; } 82 padding: 3px 7px;
83.session .who strong { color: var(--text); font-weight: 600; } 83 border: 1px solid var(--line);
84 border-radius: var(--radius-sm);
85 background: var(--surface);
86 color: var(--dim);
87 font-family: var(--mono);
88 font-size: 10px;
89 letter-spacing: 0.08em;
90}
91.session { display: flex; align-items: center; gap: 18px; }
92.session .who, .balance-block { display: grid; gap: 1px; justify-items: end; }
93.session .who .label, .balance-block .label { font-size: 9px; }
94.session .who strong { color: var(--text); font-family: var(--mono); font-size: 12px; font-weight: 600; }
95.balance-block .balance { font-size: 16px; }
84.balance { 96.balance {
85 color: var(--win); 97 color: var(--win);
86 font-family: var(--mono); 98 font-family: var(--mono);
@@ -92,7 +104,7 @@ button:disabled { cursor: not-allowed; opacity: 0.45; }
92 white-space: nowrap; 104 white-space: nowrap;
93} 105}
94 106
95main { width: min(1240px, 100%); margin: 0 auto; padding: 28px 24px 64px; flex: 1; } 107main { width: 100%; margin: 0 auto; padding: 28px 24px 48px; flex: none; }
96 108
97h1, h2, h3 { margin: 0; font-weight: 600; } 109h1, h2, h3 { margin: 0; font-weight: 600; }
98h1 { font-size: 26px; letter-spacing: -0.01em; } 110h1 { font-size: 26px; letter-spacing: -0.01em; }
@@ -175,87 +187,108 @@ h2 { font-size: 18px; }
175.auth-foot { margin-top: 16px; font-size: 12px; color: var(--faint); } 187.auth-foot { margin-top: 16px; font-size: 12px; color: var(--faint); }
176 188
177/* Lobby */ 189/* Lobby */
178.lobby-head { display: flex; align-items: end; gap: 18px; margin-bottom: 22px; flex-wrap: wrap; } 190.lobby-head { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
179.lobby-head .spacer { flex: 1; } 191.lobby-head .spacer { flex: 1; }
180.game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } 192.lobby-head h1 { display: flex; align-items: baseline; gap: 12px; }
193.count { color: var(--faint); font-family: var(--mono); font-size: 11px; font-weight: 400; letter-spacing: 0.08em; }
194.game-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
181.game-card { 195.game-card {
182 background: var(--surface); 196 background: var(--surface);
183 border: 1px solid var(--line); 197 border: 1px solid var(--line);
184 border-radius: var(--radius-lg); 198 border-radius: var(--radius-lg);
185 padding: 18px; 199 padding: 22px;
186 display: flex; 200 display: flex;
187 flex-direction: column; 201 flex-direction: column;
188 gap: 12px; 202 gap: 10px;
189 min-height: 178px; 203 min-height: 150px;
190 position: relative; 204 position: relative;
191 overflow: hidden; 205 overflow: hidden;
192} 206}
193.game-card::before { 207.game-card h3 { font-size: 22px; font-weight: 500; }
194 content: ""; 208.game-card p { margin: 0 0 10px; color: var(--dim); font-size: 13px; flex: 1; }
195 position: absolute; 209.game-card .btn { text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; padding: 9px 12px; }
196 inset: 0 auto 0 0;
197 width: 3px;
198 background: var(--accent);
199 opacity: 0.7;
200}
201.game-card.crash::before { background: var(--gold); }
202.game-card.roulette::before { background: var(--felt-line); }
203.game-card.blackjack::before { background: var(--loss); }
204.game-card h3 { font-size: 17px; }
205.game-card p { margin: 0; color: var(--dim); font-size: 13px; flex: 1; }
206.game-card .row { justify-content: space-between; }
207.bet-input { width: 92px; } 210.bet-input { width: 92px; }
208 211
209/* Game shell */ 212/* Game shell */
210.game-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; } 213#game-screen { min-height: calc(100vh - 64px - 72px); }
211.game-head .spacer { flex: 1; }
212.game-body { 214.game-body {
213 display: grid; 215 display: grid;
214 grid-template-columns: minmax(0, 1fr) 320px; 216 grid-template-columns: minmax(0, 1fr) 340px;
215 gap: 16px; 217 min-height: calc(100vh - 64px - 72px);
216 align-items: start; 218 align-items: stretch;
217} 219}
218.board { 220.board {
219 background: var(--surface); 221 background: var(--bg);
220 border: 1px solid var(--line); 222 border: 0;
221 border-radius: var(--radius-lg); 223 border-radius: 0;
222 padding: 18px; 224 padding: 24px 26px;
223 min-height: 560px; 225 min-height: 0;
224 overflow: hidden; 226 overflow: hidden;
227 display: flex;
228 flex-direction: column;
225} 229}
226.sidebar { 230.sidebar {
227 background: var(--surface); 231 background: var(--surface);
228 border: 1px solid var(--line); 232 border-left: 1px solid var(--line);
229 border-radius: var(--radius-lg); 233 border-radius: 0;
230 padding: 18px; 234 padding: 24px 26px;
231 display: flex; 235 display: flex;
232 flex-direction: column; 236 flex-direction: column;
233 gap: 14px; 237 gap: 12px;
238 min-height: 0;
239 overflow-y: auto;
240}
241.sidebar h2 { font-size: 22px; font-weight: 500; margin: 0 0 2px; }
242.sidebar .actions {
243 margin-top: auto;
244 display: grid;
245 gap: 8px;
246 padding-top: 16px;
234 position: sticky; 247 position: sticky;
235 top: 84px; 248 bottom: 0;
249 background: var(--surface);
236} 250}
237.sidebar .actions { margin-top: auto; display: grid; gap: 8px; }
238.sidebar .stat { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; } 251.sidebar .stat { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
239.sidebar .stat strong { font-family: var(--mono); font-weight: 600; } 252.sidebar .stat strong { font-family: var(--mono); font-weight: 600; }
240.note { color: var(--dim); font-size: 12px; } 253.note { color: var(--dim); font-size: 12px; }
241.chip-row { display: flex; flex-wrap: wrap; gap: 6px; } 254.chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
242.chip-btn { 255.chip-btn {
256 width: 36px;
257 height: 36px;
258 padding: 0;
243 font-family: var(--mono); 259 font-family: var(--mono);
244 font-size: 12px; 260 font-size: 11px;
261 font-weight: 700;
245 border-radius: 999px; 262 border-radius: 999px;
246 border: 1px solid var(--line); 263 border: 3px solid var(--line);
247 background: var(--raised); 264 background: var(--raised);
248 padding: 5px 10px; 265 color: var(--text);
249} 266}
250.chip-btn.active { border-color: var(--gold); color: var(--gold); background: rgba(224, 182, 74, 0.1); } 267.chip-btn[data-chip="10"] { border-color: #e8edf2; }
268.chip-btn[data-chip="25"] { border-color: #4fb8c9; }
269.chip-btn[data-chip="50"] { border-color: #e05a5a; }
270.chip-btn[data-chip="100"] { border-color: #4ec27b; }
271.chip-btn.active { box-shadow: 0 0 0 2px var(--gold); }
272.board-foot {
273 margin-top: auto;
274 padding-top: 16px;
275 display: flex;
276 align-items: baseline;
277 gap: 10px;
278 color: var(--dim);
279 font-size: 12px;
280}
281.board-foot.tight { margin-top: 14px; }
282.board-foot .label { color: var(--faint); }
251 283
252/* Slots */ 284/* Slots */
253.reels { 285.reels {
254 display: grid; 286 display: grid;
255 grid-template-columns: repeat(5, minmax(60px, 1fr)); 287 grid-template-columns: repeat(5, minmax(60px, 1fr));
256 gap: 10px; 288 gap: 12px;
257 max-width: 760px; 289 width: 100%;
258 margin: 24px auto 0; 290 max-width: 1060px;
291 margin: 90px auto 0;
259} 292}
260.reel { display: grid; gap: 8px; } 293.reel { display: grid; gap: 8px; }
261.tile { 294.tile {
@@ -271,23 +304,28 @@ h2 { font-size: 18px; }
271 color: var(--dim); 304 color: var(--dim);
272 transition: color 90ms ease, border-color 90ms ease, background 90ms ease; 305 transition: color 90ms ease, border-color 90ms ease, background 90ms ease;
273} 306}
274.tile.sym-C { color: #c8d1da; } 307.tile.sym-C { color: #e05a5a; }
275.tile.sym-L { color: #7cc4d4; } 308.tile.sym-L { color: #e0b64a; }
276.tile.sym-O { color: #d8c27a; } 309.tile.sym-O { color: #e08a3c; }
277.tile.sym-G { color: #83d09a; } 310.tile.sym-G { color: #b57edc; }
278.tile.sym-D { color: #d48d8d; } 311.tile.sym-D { color: #4fb8c9; }
279.tile.sym-S { color: #e0b64a; } 312.tile.sym-S { color: #4ec27b; }
280.tile.win { border-color: var(--win); background: rgba(78, 194, 123, 0.12); color: var(--win); } 313.tile.win { border-color: var(--gold); background: rgba(224, 182, 74, 0.14); }
281.tile.spinning { color: var(--faint); } 314.tile.spinning { opacity: 0.35; }
282.win-lines { text-align: center; margin-top: 18px; color: var(--dim); font-size: 12px; min-height: 18px; } 315.win-lines { text-align: center; margin-top: 18px; color: var(--dim); font-size: 12px; min-height: 18px; }
283 316
284/* Cards */ 317/* Cards */
285.hand { margin: 16px 0; } 318.bj-table { flex: 0 0 500px; display: flex; flex-direction: column; justify-content: space-around; min-height: 400px; max-height: 500px; }
286.hand .label { margin-bottom: 8px; } 319.bj-divider { height: 1px; background: var(--line); margin: 10px 0; }
287.cards { display: flex; gap: 8px; flex-wrap: wrap; min-height: 116px; } 320.hand { margin: 8px 0; }
321.hand-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
322.hand-head .label { color: var(--dim); }
323.hand-head .total { color: var(--text); font-size: 14px; }
324.empty-table { flex: 1; display: grid; place-items: center; color: var(--dim); font-size: 14px; }
325.cards { display: flex; gap: 10px; flex-wrap: wrap; min-height: 116px; justify-content: center; }
288.card { 326.card {
289 width: 76px; 327 width: 88px;
290 height: 108px; 328 height: 128px;
291 border-radius: 9px; 329 border-radius: 9px;
292 background: #f2f5f7; 330 background: #f2f5f7;
293 color: #121820; 331 color: #121820;
@@ -310,7 +348,8 @@ h2 { font-size: 18px; }
310.outcome { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); } 348.outcome { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); }
311 349
312/* Crash */ 350/* Crash */
313.crash-wrap { position: relative; } 351.crash-wrap { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 0; padding-top: 40px; }
352.crash-wrap > .label { margin-top: 2px; color: var(--dim); }
314.crash-number { 353.crash-number {
315 font-family: var(--mono); 354 font-family: var(--mono);
316 font-size: clamp(44px, 9vw, 86px); 355 font-size: clamp(44px, 9vw, 86px);
@@ -328,8 +367,19 @@ h2 { font-size: 18px; }
328/* Roulette */ 367/* Roulette */
329.roulette-wrap { position: relative; } 368.roulette-wrap { position: relative; }
330canvas { display: block; width: 100%; } 369canvas { display: block; width: 100%; }
331.roulette-canvas { height: 540px; } 370.roulette-canvas { height: 500px; margin: 80px 0 0; }
332.crash-canvas { height: 360px; } 371.crash-canvas {
372 height: 500px;
373 margin-top: 14px;
374 background: var(--surface);
375 border: 1px solid var(--line);
376 border-radius: var(--radius-md);
377}
378.wheel-canvas {
379 display: block;
380 margin: 6px auto 2px;
381 flex: 0 0 auto;
382}
333.wheel-result { 383.wheel-result {
334 position: absolute; 384 position: absolute;
335 top: 16px; 385 top: 16px;
@@ -357,7 +407,7 @@ td.neg { color: var(--loss); }
357 407
358/* Toasts and overlays */ 408/* Toasts and overlays */
359.site-foot { 409.site-foot {
360 width: min(1240px, 100%); 410 width: 100%;
361 margin: 0 auto; 411 margin: 0 auto;
362 padding: 0 24px 28px; 412 padding: 0 24px 28px;
363 color: var(--faint); 413 color: var(--faint);
@@ -366,7 +416,7 @@ td.neg { color: var(--loss); }
366} 416}
367.site-foot a { color: var(--dim); } 417.site-foot a { color: var(--dim); }
368 418
369#toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; width: min(380px, calc(100vw - 36px)); } 419#toasts { position: fixed; left: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; width: min(340px, calc(100vw - 36px)); }
370.toast { 420.toast {
371 background: var(--surface); 421 background: var(--surface);
372 border: 1px solid var(--line); 422 border: 1px solid var(--line);
@@ -400,9 +450,11 @@ td.neg { color: var(--loss); }
400.modal p { color: var(--dim); } 450.modal p { color: var(--dim); }
401 451
402@media (max-width: 900px) { 452@media (max-width: 900px) {
403 .game-body { grid-template-columns: 1fr; } 453 .game-body { grid-template-columns: 1fr; min-height: 0; }
404 .sidebar { position: static; } 454 .sidebar { border-left: 0; border-top: 1px solid var(--line); position: static; }
455 .board { padding: 18px 14px; }
405 .tagline { display: none; } 456 .tagline { display: none; }
457 .game-grid { grid-template-columns: 1fr; }
406} 458}
407@media (max-width: 620px) { 459@media (max-width: 620px) {
408 main { padding: 18px 14px 54px; } 460 main { padding: 18px 14px 54px; }
@@ -413,5 +465,5 @@ td.neg { color: var(--loss); }
413 .tile { font-size: clamp(18px, 7vw, 34px); } 465 .tile { font-size: clamp(18px, 7vw, 34px); }
414 .card { width: 58px; height: 84px; } 466 .card { width: 58px; height: 84px; }
415 .crash-canvas { height: 260px; } 467 .crash-canvas { height: 260px; }
416 .roulette-canvas { height: 400px; } 468 .roulette-canvas { height: 400px; margin-top: 30px; }
417} 469}
diff --git a/web/play/test/smoke.mjs b/web/play/test/smoke.mjs
index c1274bc..962753b 100644
--- a/web/play/test/smoke.mjs
+++ b/web/play/test/smoke.mjs
@@ -207,8 +207,8 @@ socket.message({
207 }, 207 },
208}); 208});
209assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders"); 209assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders");
210assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders"); 210assert(document.querySelectorAll(".chip-btn").length === 4, "the chip selector renders");
211assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders"); 211assert(document.querySelector(".board-foot")?.textContent.includes("5"), "the result number renders");
212 212
213socket.message({ 213socket.message({
214 type: "view", 214 type: "view",
@@ -259,7 +259,7 @@ socket.message({
259assert(document.querySelector(".crash-canvas"), "the crash canvas renders"); 259assert(document.querySelector(".crash-canvas"), "the crash canvas renders");
260assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders"); 260assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders");
261assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running"); 261assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running");
262assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment"); 262assert(document.querySelector(".crash-meta")?.textContent.includes("commit"), "crash shows the round commitment");
263 263
264socket.message({ 264socket.message({
265 type: "view", 265 type: "view",