From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- web/play/.gitignore | 2 + web/play/README.md | 56 +++ web/play/app.js | 1 + web/play/build.mjs | 22 + web/play/index.html | 112 +++++ web/play/package-lock.json | 1031 ++++++++++++++++++++++++++++++++++++++++++++ web/play/package.json | 20 + web/play/src/blackjack.ts | 152 +++++++ web/play/src/crash.ts | 365 ++++++++++++++++ web/play/src/dom.ts | 104 +++++ web/play/src/history.ts | 60 +++ web/play/src/main.ts | 323 ++++++++++++++ web/play/src/net.ts | 120 ++++++ web/play/src/roulette.ts | 474 ++++++++++++++++++++ web/play/src/slots.ts | 144 +++++++ web/play/src/types.ts | 27 ++ web/play/style.css | 407 +++++++++++++++++ web/play/test/smoke.mjs | 294 +++++++++++++ web/play/tsconfig.json | 13 + 19 files changed, 3727 insertions(+) create mode 100644 web/play/.gitignore create mode 100644 web/play/README.md create mode 100644 web/play/app.js create mode 100644 web/play/build.mjs create mode 100644 web/play/index.html create mode 100644 web/play/package-lock.json create mode 100644 web/play/package.json create mode 100644 web/play/src/blackjack.ts create mode 100644 web/play/src/crash.ts create mode 100644 web/play/src/dom.ts create mode 100644 web/play/src/history.ts create mode 100644 web/play/src/main.ts create mode 100644 web/play/src/net.ts create mode 100644 web/play/src/roulette.ts create mode 100644 web/play/src/slots.ts create mode 100644 web/play/src/types.ts create mode 100644 web/play/style.css create mode 100644 web/play/test/smoke.mjs create mode 100644 web/play/tsconfig.json (limited to 'web/play') diff --git a/web/play/.gitignore b/web/play/.gitignore new file mode 100644 index 0000000..63738f6 --- /dev/null +++ b/web/play/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +app.js.map diff --git a/web/play/README.md b/web/play/README.md new file mode 100644 index 0000000..610c7a8 --- /dev/null +++ b/web/play/README.md @@ -0,0 +1,56 @@ +# `web/play` — browser frontend + +Vanilla TypeScript + esbuild. The browser sends `Command` JSON and renders +`View` JSON; it contains no game rules, no payout math and no wire protocol. +The gateway in `webd/` owns the upstream TLS connection. + +## Build + +```sh +npm ci +npm run build # writes app.js +npm run typecheck +npm test # headless DOM smoke test (jsdom) +``` + +Commit the generated `app.js` so the site deploys without Node. + +## Local development + +Serve the repository's `web/` directory (for `/play/`) and put the gateway +behind the same origin at `/ws`. The simplest local setup is nginx with the +`web/nginx.conf.example` block pointed at a staging root and +`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899`. For the app alone: + +```sh +python3 -m http.server 8900 -d web/play +``` + +It will load, but the WebSocket will only connect through nginx or another +proxy that routes `/ws` to `webd`. + +## Screens + +- Auth: login/register. The password is hashed with Web Crypto (`SHA-256`) + before it leaves the page; the gateway and browser never store it. +- Lobby: four games, per-game bet, history. +- Slots: five reels, win cells and paylines. +- Roulette: canvas felt with straight, dozen, column and outside chips; + left click adds, right click removes. +- Blackjack: dealer/player cards, hit/stand/double/forfeit. +- Crash: curve, live multiplier, cash-out, commit/seed verification. +- History: settled plays with net result. + +## Theme + +`style.css` mirrors the palette in `gui/src/theme.rs`: near-black ground, +cyan focus, green for wins, gold for chips, felt green only for roulette. +Do not introduce a second palette or hard-code colours in the render modules. + +## Security + +- The session cookie is HttpOnly and SameSite=Strict; JavaScript never reads + it and never stores a credential. +- Notices are inserted with `textContent`, not HTML. +- The app is play-money only. Do not add deposits, withdrawals, KYC or + real-money claims without a product decision and an ADR. diff --git a/web/play/app.js b/web/play/app.js new file mode 100644 index 0000000..3320693 --- /dev/null +++ b/web/play/app.js @@ -0,0 +1 @@ +"use strict";(()=>{function k(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 Oe(e){let n=e<0,t=String(Math.abs(Math.trunc(e))),s="";for(let a=0;a0&&(t.length-a)%3===0&&(s+=" "),s+=t[a];return n?`-${s}`:s}function L(e){return e==null||Number.isNaN(e)?"— Kr":`${Oe(e)} Kr`}function me(e){return`${e<0?"-":"+"}${L(Math.abs(e))}`}function J(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 he(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 fe(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 O(e,n=10){return e.length<=n*2?e:`${e.slice(0,n)}…${e.slice(-n)}`}function be(e){let n=Date.parse(e.includes("T")?e:e.replace(" ","T")+"Z");return Number.isNaN(n)?e:new Date(n).toLocaleString()}function ge(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 j=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)}};function ze(e){if(e==="?"){let s=o("div","card back");return s.append(o("span","rank","?"),o("span","suit","")),s}let n=ge(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 ye(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 Ve(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 ke(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(ye("Dealer",a.dealer??[],Number(a.dealer_total??0))),e.append(ye("You",a.player??[],Number(a.player_total??0)));else{let h=o("div","outcome");h.textContent="Press DEAL to start a hand.",e.append(h)}if(d||a&&!r){let h=o("div","outcome");h.textContent=Ve(t,a),(String(a?.result)==="win"||String(a?.result)==="blackjack"||String(a?.result)==="push")&&h.classList.add("win-text"),e.append(h)}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 h=o("div","row"),v=o("button","btn small","−10"),y=o("strong","mono",L(m)),w=o("button","btn small","+10");v.addEventListener("click",()=>s({AdjustBet:-10})),w.addEventListener("click",()=>s({AdjustBet:10})),h.append(v,y,w),n.append(o("div","label","Bet"),h)}else{let h=o("div","stat");h.append(o("span","muted","Bet"),o("strong",void 0,L(m))),n.append(h)}if(p!==void 0){let h=o("div","stat");h.append(o("span","muted","Balance"),o("strong",void 0,L(Number(p)))),n.append(h)}let u=o("div","actions");if(r){let h=o("div","row wrap");for(let y of a?.actions??[]){let w=y==="hit"?"HIT":y==="stand"?"STAND":"DOUBLE",E=y==="stand"?o("button","btn primary",w):o("button","btn",w);E.addEventListener("click",()=>{s(y==="hit"?"Hit":y==="stand"?"Stand":"Double")}),h.append(E)}u.append(h);let v=o("button","btn danger wide","Forfeit hand");v.addEventListener("click",()=>{window.confirm("Forfeit this hand? The reserved bet is lost.")&&s("Forfeit")}),u.append(v)}else if(i){let h=o("button","btn primary wide big","DEAL");h.addEventListener("click",()=>s("Deal")),u.append(h)}let g=o("button","btn ghost wide",r?"Leave (forfeits)":"Leave table");g.addEventListener("click",()=>{r&&!window.confirm("Leaving forfeits this hand. Continue?")||s("Leave")}),u.append(g),n.append(u)}var q=Math.LN2/5,R=null,z=null,ve=null,P=null,W,Le=0,Y="";function _e(e,n,t,s){H(e),H(n),P=t,ve=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"),g=o("strong","mono","—"),h=o("button","btn small","+10");u.addEventListener("click",()=>P?.({AdjustBet:-10})),h.addEventListener("click",()=>P?.({AdjustBet:10})),p.append(u,g,h),n.append(o("div","label","Bet"),p);let v=o("strong","mono","—"),y=o("strong","mono","—");for(let[M,N]of[["Potential",v],["Balance",y]]){let b=o("div","stat");b.append(o("span","muted",M),N),n.append(b)}let w=o("p","note","Ready."),E=o("p","note",""),S=o("p","error-text");n.append(w,E,S);let l=o("div","actions"),c=o("button","btn primary wide big","START"),f=o("button","btn ghost wide","Leave table");return c.addEventListener("click",()=>{z?.status==="running"?P?.("CashOut"):P?.("Start")}),f.addEventListener("click",()=>{z?.status==="running"&&!window.confirm("Forfeit this round? The bet is lost.")||P?.("Leave")}),l.append(c,f),n.append(l),{board:e,side:n,number:r,meta:d,verify:m,canvas:i,bet:g,potential:v,balance:y,status:w,lastCrash:E,error:S,primary:c,leave:f,minus:u,plus:h}}function we(e,n,t,s,a){(!R||R.board!==e)&&(R=_e(e,n,s,a),Y="");let r=R;P=s,ve=a,z=t,t.status!==Y&&((t.status==="starting"||t.status==="running")&&Y!=="running"&&(r.verify.className="verify",r.verify.textContent="Round committed; seed hidden until the round ends."),Y=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 ${O(p,12)}`:"no commitment yet",t.seed&&p){let u=++Le,g=String(t.seed),h=Number(t.crash_point??0);Xe(g,p,h).then(([v,y])=>{if(u!==Le||!R)return;let w=v&&y;R.verify.className=`verify ${w?"ok":"bad"}`,R.verify.textContent=w?`Seed ${O(g,12)} · sha256 matches commit · crash point recomputed`:`VERIFICATION FAILED · seed ${O(g,12)} · commit ${O(p,12)}`})}else!m&&!p&&(r.verify.className="verify",r.verify.textContent="Waiting for a round commitment.");if(W!==void 0&&(cancelAnimationFrame(W),W=void 0),d){let u=()=>{if(!R||z?.status!=="running"){W=void 0;return}xe(R,z,a),W=requestAnimationFrame(u)};W=requestAnimationFrame(u)}else xe(r,t,a)}function je(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(q*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 xe(e,n,t){let s=je(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)),Ye(e.canvas,n,s,t)}function Ye(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,g=34,h=Math.max(1,a-m-p),v=Math.max(1,r-u-g),y=n.status==="running"?Math.max(.25,(s.now()-Number(n.started_at??0))/1e3):t>1?Math.log(t)/q:1,w=Math.max(5,y*1.08),E=Math.max(2,Math.ceil(t*1.15*2)/2),S=b=>m+b/w*h,l=b=>u+v-(b-1)/(E-1)*v;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=w*b/4,C=S(T);i.beginPath(),i.moveTo(C,u),i.lineTo(C,u+v),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 f=180;for(let b=0;b<=f;b+=1){let T=y*b/f,C=Math.min(E,Math.exp(q*T)),ue=S(T),pe=l(C);b===0?i.moveTo(ue,pe):i.lineTo(ue,pe)}i.strokeStyle=c,i.lineWidth=2.4,i.stroke();let M=S(y),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 Xe(e,n,t){let s=new Uint8Array(e.length/2);for(let u=0;uu.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 Ge(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 Te(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",be(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",Ge(m)));let u=Number(m.winnings??0)-Number(m.bet??0),g=o("td",`num ${u>0?"pos":u<0?"neg":""}`,u===0?"0 Kr":me(u));p.append(g),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 Ke="#b0313a",Ue="#151b21",Ee="#106b45",Ze="#0d5c39",Je="#09472c",qe="#1f7d52",Qe="#ecf7f0",et="#e0b64a";function tt(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 nt(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,g=14,h=g+p*3+d,v=h+r+d,y=[];for(let l=0;l<12;l+=1)for(let c=0;c<3;c+=1){let f=l*3+(3-c);y.push({x:u+l*m,y:g+c*p,w:m,h:p,target:{bet:"straight",number:f},label:String(f),kind:"number",number:f})}y.push({x:14,y:g,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;y.push({x:u+i,y:g+l*p,w:a,h:p,target:{bet:"column",index:c},label:"2:1",kind:"column"})}for(let l=0;l<3;l+=1)y.push({x:u+i/3*l,y:h,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)=>{y.push({x:u+i/6*c,y:v,w:i/6,h:r,target:{bet:"outside",kind:l.kind},label:l.label,kind:"outside"})});let E=[],S=l=>{let c=y.find(f=>f.kind==="number"&&f.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 f=l*3+(3-c);E.push({x:u+(l+1)*m,y:g+(c+1)*p,radius:12,rank:0,target:{bet:"corner",numbers:[f,f-1,f+3,f+2]}})}for(let l=0;l<12;l+=1)for(let c=0;c<2;c+=1){let f=l*3+(3-c),M=S(f);M&&E.push({x:M.x,y:g+(c+1)*p,radius:10,rank:1,target:{bet:"split",a:f,b:f-1}})}for(let l=0;l<11;l+=1)for(let c=0;c<3;c+=1){let f=l*3+(3-c);E.push({x:u+(l+1)*m,y:g+c*p+p/2,radius:10,rank:1,target:{bet:"split",a:f,b:f+3}})}for(let l=0;l<3;l+=1){let c=3-l;E.push({x:u,y:g+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:g,gridW:i,columnX:u+i,dozensY:h,outsideY:v,outsideH:r,zones:y,hotspots:E}}function Me(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 Ce(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=nt(t,s);if(!r)return d;r.setTransform(a,0,0,a,0,0),r.clearRect(0,0,t,s),r.fillStyle=Je,Me(r,0,0,t,s,10),r.fill();for(let i of d.zones){let m=Ze;i.kind==="number"?m=i.number===0?Ee:tt(i.number)==="red"?Ke:Ue:i.kind==="zero"&&(m=Ee),r.fillStyle=m,r.strokeStyle=qe,r.lineWidth=1,Me(r,i.x+1,i.y+1,i.w-2,i.h-2,3),r.fill(),r.stroke(),r.fillStyle=Qe,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 st(r,d,n),d}function Se(e){return JSON.stringify(e)}function ot(e,n){let t=Se(n),s=e.zones.find(a=>Se(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 st(e,n,t){let s=t.bets?.chips??[];for(let a of s){let r=ot(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=et,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 Ne(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=Ce(d,t),p=(l,c)=>{let f=d.getBoundingClientRect(),M=l-f.left,N=c-f.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=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"}),new ResizeObserver(()=>{m=Ce(d,t)}).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 g=(t.bets?.chips??[]).reduce((l,c)=>l+Number(c.amount??0),0),h=o("div","stat");if(h.append(o("span","muted","On the felt"),o("strong","mono",L(g))),n.append(h),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 v=o("p","note");if(a)v.textContent="Spinning…";else if(t.phase==="result"){let l=Number(t.winnings??0);v.textContent=`${t.number} ${t.color} · ${l>0?`won ${L(l)}`:"no win"}`,v.className=l>0?"note win-text":"note"}else v.textContent=g>0?"Ready to spin.":"Place at least one chip.";n.append(v);let y=o("div","actions"),w=o("button","btn primary wide big",a?"SPINNING…":"SPIN WHEEL");w.disabled=a||g===0,w.addEventListener("click",()=>s("Spin"));let E=o("button","btn wide","Clear bets");E.disabled=a||g===0,E.addEventListener("click",()=>s("ClearBets"));let S=o("button","btn ghost wide","Leave table");S.disabled=a,S.addEventListener("click",()=>{a||s("Leave")}),y.append(w,E,S),n.append(y)}var Q=5,ee=3,D="CLOGDS",rt=["top","middle","bottom","V","inverted V"],oe=0,I,te=null,ne=null;function at(){oe=Date.now()+900}function it(e){return e.phase==="spinning"||Date.now()0){let f=(t.win_lines??[]).map(M=>`${M} (${rt[M-1]??"line"})`).join(" · ");u.textContent=`${t.win_type==="mega"?"MEGA WIN":t.win_type==="big"?"BIG WIN":"WIN"} ${L(c)}${f?` · ${f}`:""}`,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;I=window.setInterval(()=>{c+=1;for(let f=0;f=oe&&te&&ne&&(window.clearInterval(I),I=void 0,se(ne,n,te,s))},70)}n.append(o("div","label","Slots")),n.append(o("p","note","5 reels × 3 rows, 5 paylines."));let g=o("div","row"),h=o("button","btn small","−10"),v=o("strong","mono",L(Number(t.bet??0))),y=o("button","btn small","+10");h.disabled=a,y.disabled=a,h.addEventListener("click",()=>s({AdjustBet:-10})),y.addEventListener("click",()=>s({AdjustBet:10})),g.append(h,v,y),n.append(o("div","label","Bet"),g),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 c=Number(t.winnings??0);w.textContent=c>0?`Won ${L(c)}`:"No win this spin.",c>0&&w.classList.add("win-text")}else w.textContent="Ready when you are.";n.append(w);let E=o("div","actions"),S=o("button","btn primary wide big",a?"SPINNING…":"SPIN");S.disabled=a,S.addEventListener("click",()=>{at(),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 lt=k("auth-screen"),ct=k("lobby-screen"),dt=k("game-screen"),ut=k("history-screen"),He=k("session-bar"),pt=k("account-name"),mt=k("account-balance"),re=k("game-grid"),X=k("game-board"),G=k("game-side"),ht=k("game-title"),$e=k("game-badge"),ft=k("history-body"),bt=k("auth-form"),Ae=k("auth-user"),U=k("auth-pass"),F=k("auth-error"),A=k("auth-submit"),Pe=k("tab-login"),We=k("tab-register"),K=k("reconnect-overlay"),Be=k("reconnect-message"),Re=k("replaced-overlay"),x=null,$=null,ce=null,_="login",ae=null,De=0,ie=0,le=new Map([["slots",10],["roulette",10],["blackjack",10],["crash",10]]),Ie={slots:"Slots",roulette:"Roulette",blackjack:"Blackjack",crash:"Crash"},gt={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."},yt=location.pathname.includes("/play/")?location.pathname.slice(0,location.pathname.indexOf("/play/")):"",kt=`${location.protocol==="https:"?"wss":"ws"}://${location.host}${yt}/ws`,Z=new j(kt,{onHello(e){De=e.time-Date.now(),$=e.user??null,$e.textContent=`v${e.version}`},onView(e){x=e,typeof e.balance=="number"&&(ce=e.balance),e.screen==="lobby"&&"user"in e&&($=e.user??null,$?U.value="":ce=null),ie=0,K.hidden=!0,Fe(),$&&x.balance===null&&ae!==$&&(ae=$,Z.send("Info")),$||(ae=null)},onNotice(e){e.startsWith("Connected to:")||e.startsWith("Fingerprint:")||e.startsWith("User:")||e.startsWith("Account:")||e.startsWith("Balance:")||(J(e,he(e)),e.startsWith("Account created")&&(de("login"),U.value="",Ae.focus()),A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account")},onError(e){J(e,"bad"),A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account"},onReplaced(){Re.hidden=!1},onPong(){},onOpen(){K.hidden=!0},onClosed(e){if(Re.hidden!==!1){if(!e){Be.textContent="The connection could not be restored. Reload the page to try again.",K.hidden=!1;return}ie+=1,Be.textContent=ie>1?"Still trying to reach the game server…":"Trying to reach the game server…",K.hidden=!1}}});function B(e){Z.send(e)}function Lt(){return Date.now()+De}function de(e){_=e,Pe.classList.toggle("active",e==="login"),We.classList.toggle("active",e==="register"),A.textContent=e==="login"?"Log in":"Create account",U.autocomplete=e==="login"?"current-password":"new-password",F.textContent=""}function V(e){lt.hidden=e!=="auth",ct.hidden=e!=="lobby",dt.hidden=e!=="game",ut.hidden=e!=="history"}function xt(e){mt.textContent=L(e??null)}function Fe(){if(!x)return;let e=$;if(e?(He.hidden=!1,pt.textContent=String(e)):He.hidden=!0,xt(ce),!e){V("auth"),F.textContent=x.login_error?String(x.login_error):"",x.screen==="lobby"&&x.connected===!1&&(F.textContent="The game server is not reachable yet. Retrying…");return}switch(x.screen){case"lobby":V("lobby"),wt(x);break;case"history":V("history"),Te(ft,x);break;case"slots":case"roulette":case"blackjack":case"crash":V("game"),ht.textContent=Ie[x.screen]??x.screen,$e.textContent=vt(x),x.screen==="slots"?se(X,G,x,B):x.screen==="roulette"?Ne(X,G,x,B):x.screen==="blackjack"?ke(X,G,x,B):we(X,G,x,B,{now:Lt});break;default:V("auth")}}function vt(e){return e.screen==="crash"||e.screen==="blackjack"?String(e.status??""):e.screen==="slots"||e.screen==="roulette"?String(e.phase??""):""}function wt(e){re.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||Ie[t]||t)),a.append(o("p",void 0,gt[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(le.get(t)??10),i.addEventListener("change",()=>{let p=Math.max(1,Math.floor(Number(i.value)||1));i.value=String(p),le.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));le.set(t,p),B({Play:{game:t,bet:p}})}),r.append(d,m),a.append(r),re.append(a)}n.length===0&&re.append(o("p","muted","The server did not advertise any games."))}Pe.addEventListener("click",()=>de("login"));We.addEventListener("click",()=>de("register"));bt.addEventListener("submit",async e=>{e.preventDefault();let n=Ae.value.trim(),t=U.value;if(!n||!t){F.textContent="Enter a username and a password.";return}A.disabled=!0,A.textContent="Working…",F.textContent="";try{let s=await fe(t);B(_==="login"?{Login:{user:n,credential:s}}:{Register:{user:n,credential:s}})}catch{F.textContent="This browser cannot hash the password (needs a secure context).",A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account"}});k("logout").addEventListener("click",()=>B("Logout"));k("show-history").addEventListener("click",()=>B({History:{limit:50}}));k("refresh-lobby").addEventListener("click",()=>B("Info"));k("history-back").addEventListener("click",()=>B("ShowLobby"));k("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?")||B("Leave")});k("reload-replaced").addEventListener("click",()=>location.reload());k("reload-reconnect").addEventListener("click",()=>location.reload());window.addEventListener("resize",()=>{window.clearTimeout(window.__resizeTimer),window.__resizeTimer=window.setTimeout(()=>{x&&Fe()},140)});window.setInterval(()=>{$&&Z.ping()},25e3);Z.connect();})(); diff --git a/web/play/build.mjs b/web/play/build.mjs new file mode 100644 index 0000000..40af074 --- /dev/null +++ b/web/play/build.mjs @@ -0,0 +1,22 @@ +import { build, context } from "esbuild"; + +const watch = process.argv.includes("--watch"); + +const options = { + entryPoints: ["src/main.ts"], + bundle: true, + outfile: "app.js", + format: "iife", + target: ["es2020"], + minify: !watch, + sourcemap: watch ? "inline" : false, + charset: "utf8", + logLevel: "info", +}; + +if (watch) { + const ctx = await context(options); + await ctx.watch(); +} else { + await build(options); +} diff --git a/web/play/index.html b/web/play/index.html new file mode 100644 index 0000000..e9d4b79 --- /dev/null +++ b/web/play/index.html @@ -0,0 +1,112 @@ + + + + + +Play — żagań.bet + + + + + + +
+
+ żagań.bet + Play credits · no real money + + +
+ +
+ + + + + + + +
+
+ +
+ + + + + + + + diff --git a/web/play/package-lock.json b/web/play/package-lock.json new file mode 100644 index 0000000..831527f --- /dev/null +++ b/web/play/package-lock.json @@ -0,0 +1,1031 @@ +{ + "name": "zagan-bet-play", + "version": "5.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zagan-bet-play", + "version": "5.0.0", + "devDependencies": { + "esbuild": "^0.28.0", + "jsdom": "^30.1.3", + "typescript": "^5.9.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", + "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", + "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/play/package.json b/web/play/package.json new file mode 100644 index 0000000..3e3cebf --- /dev/null +++ b/web/play/package.json @@ -0,0 +1,20 @@ +{ + "name": "zagan-bet-play", + "private": true, + "version": "5.0.0", + "type": "module", + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "node test/smoke.mjs" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "jsdom": "^30.1.3", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/web/play/src/blackjack.ts b/web/play/src/blackjack.ts new file mode 100644 index 0000000..3537f45 --- /dev/null +++ b/web/play/src/blackjack.ts @@ -0,0 +1,152 @@ +import type { View } from "./types"; +import { cardFace, clear, money, node } from "./dom"; + +function cardElement(card: string): HTMLElement { + if (card === "?") { + const back = node("div", "card back"); + back.append(node("span", "rank", "?"), node("span", "suit", "")); + return back; + } + const face = cardFace(card); + const element = node("div", "card"); + if (face.red) element.classList.add("red"); + element.append(node("span", "rank", face.rank), node("span", "suit", face.suit)); + return element; +} + +function handBlock(label: string, cards: string[], total: number): HTMLElement { + const block = node("div", "hand"); + block.append(node("div", "label", label)); + const row = node("div", "cards"); + for (const card of cards) row.append(cardElement(card)); + const sum = node("div", "total"); + sum.append(document.createTextNode("Total "), node("strong", undefined, String(total))); + block.append(row, sum); + return block; +} + +function outcome(view: View, hand: any): string { + const result = String(hand?.result ?? ""); + const winnings = Number(hand?.winnings ?? 0); + switch (result) { + case "blackjack": + return `Blackjack — won ${money(winnings)}`; + case "win": + return `You win — ${money(winnings)}`; + case "push": + return `Push — ${money(winnings)} 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 view.status === "player" ? "Your move." : "Dealing…"; + default: + return "Ready to deal."; + } +} + +export function renderBlackjack( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + clear(board); + clear(side); + + const hand = view.hand; + const playerPhase = hand?.phase === "player" || view.status === "player"; + const done = hand?.phase === "done" || view.status === "done"; + const canDeal = !hand || done; + + board.append(node("h2", undefined, "Blackjack")); + board.append( + node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."), + ); + + if (hand) { + board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); + board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0))); + } else { + const empty = node("div", "outcome"); + empty.textContent = "Press DEAL to start a hand."; + board.append(empty); + } + + if (done || (hand && !playerPhase)) { + const result = node("div", "outcome"); + result.textContent = outcome(view, hand); + if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") { + result.classList.add("win-text"); + } + board.append(result); + } + + if (view.error) board.append(node("p", "error-text", String(view.error))); + + // Sidebar + side.append(node("div", "label", "Blackjack")); + const bet = Number(hand?.bet ?? view.bet ?? 0); + const balance = view.balance ?? view.hand?.balance; + side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet.")); + + if (canDeal) { + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10"); + const amount = node("strong", "mono", money(bet)); + const plus = node("button", "btn small", "+10"); + minus.addEventListener("click", () => send({ AdjustBet: -10 })); + plus.addEventListener("click", () => send({ AdjustBet: 10 })); + betRow.append(minus, amount, plus); + side.append(node("div", "label", "Bet"), betRow); + } else { + const stat = node("div", "stat"); + stat.append(node("span", "muted", "Bet"), node("strong", undefined, money(bet))); + side.append(stat); + } + + if (balance !== undefined) { + const stat = node("div", "stat"); + stat.append(node("span", "muted", "Balance"), node("strong", undefined, money(Number(balance)))); + side.append(stat); + } + + const actions = node("div", "actions"); + if (playerPhase) { + const actionsRow = node("div", "row wrap"); + for (const action of hand?.actions ?? []) { + const label = action === "hit" ? "HIT" : action === "stand" ? "STAND" : "DOUBLE"; + const button = + action === "stand" + ? node("button", "btn primary", label) + : node("button", "btn", label); + button.addEventListener("click", () => { + if (action === "hit") send("Hit"); + else if (action === "stand") send("Stand"); + else send("Double"); + }); + actionsRow.append(button); + } + actions.append(actionsRow); + const forfeit = node("button", "btn danger wide", "Forfeit hand"); + forfeit.addEventListener("click", () => { + if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit"); + }); + actions.append(forfeit); + } else if (canDeal) { + const deal = node("button", "btn primary wide big", "DEAL"); + deal.addEventListener("click", () => send("Deal")); + actions.append(deal); + } + + const leave = node("button", "btn ghost wide", playerPhase ? "Leave (forfeits)" : "Leave table"); + leave.addEventListener("click", () => { + if (playerPhase && !window.confirm("Leaving forfeits this hand. Continue?")) return; + send("Leave"); + }); + actions.append(leave); + side.append(actions); +} diff --git a/web/play/src/crash.ts b/web/play/src/crash.ts new file mode 100644 index 0000000..6f3c8dd --- /dev/null +++ b/web/play/src/crash.ts @@ -0,0 +1,365 @@ +import type { View } from "./types"; +import { clear, money, node, shortHash } from "./dom"; + +const GROWTH = Math.LN2 / 5; + +type Clock = { now: () => number }; + +type Parts = { + board: HTMLElement; + side: HTMLElement; + number: HTMLElement; + meta: HTMLElement; + verify: HTMLElement; + canvas: HTMLCanvasElement; + bet: HTMLElement; + potential: HTMLElement; + balance: HTMLElement; + status: HTMLElement; + lastCrash: HTMLElement; + error: HTMLElement; + primary: HTMLButtonElement; + leave: HTMLButtonElement; + minus: HTMLButtonElement; + plus: HTMLButtonElement; +}; + +let parts: Parts | null = null; +let current: View | null = null; +let clock: Clock | null = null; +let sendCommand: ((command: unknown) => void) | null = null; +let frameHandle: number | undefined; +let verifyToken = 0; +let lastStatus = ""; + +function build( + board: HTMLElement, + side: HTMLElement, + send: (command: unknown) => void, + now: Clock, +): Parts { + clear(board); + clear(side); + sendCommand = send; + clock = now; + + board.append(node("h2", undefined, "Crash")); + board.append( + node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."), + ); + + const wrap = node("div", "crash-wrap"); + const number = node("div", "crash-number", "1.00×"); + const meta = node("div", "crash-meta"); + const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; + const verify = node("div", "verify", "Waiting for a round commitment."); + wrap.append(number, meta, canvas, verify); + board.append(wrap); + + side.append(node("div", "label", "Crash")); + side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); + + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10") as HTMLButtonElement; + const bet = node("strong", "mono", "—"); + const plus = node("button", "btn small", "+10") as HTMLButtonElement; + minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); + plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); + betRow.append(minus, bet, plus); + side.append(node("div", "label", "Bet"), betRow); + + const potential = node("strong", "mono", "—"); + const balance = node("strong", "mono", "—"); + for (const [label, value] of [ + ["Potential", potential], + ["Balance", balance], + ] as const) { + const row = node("div", "stat"); + row.append(node("span", "muted", label), value); + side.append(row); + } + + const status = node("p", "note", "Ready."); + const lastCrash = node("p", "note", ""); + const error = node("p", "error-text"); + side.append(status, lastCrash, error); + + const actions = node("div", "actions"); + const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement; + const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement; + primary.addEventListener("click", () => { + if (current?.status === "running") sendCommand?.("CashOut"); + else sendCommand?.("Start"); + }); + leave.addEventListener("click", () => { + if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return; + sendCommand?.("Leave"); + }); + actions.append(primary, leave); + side.append(actions); + + return { + board, + side, + number, + meta, + verify, + canvas, + bet, + potential, + balance, + status, + lastCrash, + error, + primary, + leave, + minus, + plus, + }; +} + +export function renderCrash( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, + now: Clock, +): void { + if (!parts || parts.board !== board) { + parts = build(board, side, send, now); + lastStatus = ""; + } + const p = parts; + sendCommand = send; + clock = now; + current = view; + + if (view.status !== lastStatus) { + if (view.status === "starting" || view.status === "running") { + if (lastStatus !== "running") { + p.verify.className = "verify"; + p.verify.textContent = "Round committed; seed hidden until the round ends."; + } + } + lastStatus = String(view.status); + } + + const running = view.status === "running"; + const starting = view.status === "starting"; + const live = running || starting; + + p.bet.textContent = money(Number(view.bet ?? 0)); + p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0)); + p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance)); + p.error.textContent = view.error ? String(view.error) : ""; + + p.minus.disabled = live; + p.plus.disabled = live; + p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START"; + p.primary.disabled = starting; + p.primary.className = running ? "btn primary wide big" : "btn primary wide big"; + p.leave.textContent = running ? "Forfeit round" : "Leave table"; + + if (running) { + p.status.textContent = "Rising — cash out before the crash."; + p.status.className = "note win-text"; + } else if (starting) { + p.status.textContent = "Starting the round…"; + p.status.className = "note"; + } else if (view.status === "cashed") { + p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`; + p.status.className = "note win-text"; + } else if (view.status === "crashed") { + p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`; + p.status.className = "note error-text"; + } else if (view.status === "forfeit") { + p.status.textContent = "Round forfeited."; + p.status.className = "note error-text"; + } else { + p.status.textContent = "Ready. Start a round when you want."; + p.status.className = "note"; + } + + if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") { + p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`; + } + + const commit = view.commit ? String(view.commit) : ""; + p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; + + if (view.seed && commit) { + const token = ++verifyToken; + const seed = String(view.seed); + const crashPoint = Number(view.crash_point ?? 0); + void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => { + if (token !== verifyToken || !parts) return; + const ok = commitOk && pointOk; + parts.verify.className = `verify ${ok ? "ok" : "bad"}`; + parts.verify.textContent = ok + ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed` + : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`; + }); + } else if (!live && !commit) { + p.verify.className = "verify"; + p.verify.textContent = "Waiting for a round commitment."; + } + + if (frameHandle !== undefined) { + cancelAnimationFrame(frameHandle); + frameHandle = undefined; + } + if (running) { + const loop = () => { + if (!parts || current?.status !== "running") { + frameHandle = undefined; + return; + } + update(parts, current, now); + frameHandle = requestAnimationFrame(loop); + }; + frameHandle = requestAnimationFrame(loop); + } else { + update(p, view, now); + } +} + +function currentMultiplier(view: View, now: Clock): number { + if (view.status === "running") { + const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000); + const local = Math.min(1000, Math.exp(GROWTH * elapsed)); + return Math.max(local, Number(view.multiplier ?? 1)); + } + if (view.status === "cashed") return Number(view.multiplier ?? 1); + if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1); + return 1; +} + +function update(p: Parts, view: View, now: Clock): void { + const multiplier = currentMultiplier(view, now); + p.number.textContent = `${multiplier.toFixed(2)}×`; + p.number.className = `crash-number ${ + view.status === "running" + ? "running" + : view.status === "cashed" + ? "cashed" + : view.status === "crashed" + ? "crashed" + : "" + }`; + p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier)); + drawChart(p.canvas, view, multiplier, now); +} + +function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void { + const width = canvas.clientWidth || 600; + const height = canvas.clientHeight || 360; + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + const padL = 54; + const padR = 16; + const padT = 18; + const padB = 34; + const plotW = Math.max(1, width - padL - padR); + const plotH = Math.max(1, height - padT - padB); + + const elapsed = + view.status === "running" + ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000) + : multiplier > 1 + ? Math.log(multiplier) / GROWTH + : 1; + const xMax = Math.max(5, elapsed * 1.08); + const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2); + const x = (seconds: number) => padL + (seconds / xMax) * plotW; + const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH; + + ctx.strokeStyle = "#242e38"; + ctx.fillStyle = "#8a98a6"; + ctx.font = "11px ui-monospace, monospace"; + ctx.lineWidth = 1; + for (let step = 0; step <= 4; step += 1) { + const value = 1 + ((yMax - 1) * step) / 4; + const py = y(value); + ctx.beginPath(); + ctx.moveTo(padL, py); + ctx.lineTo(width - padR, py); + ctx.stroke(); + ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); + } + for (let step = 0; step <= 4; step += 1) { + const seconds = (xMax * step) / 4; + const px = x(seconds); + ctx.beginPath(); + ctx.moveTo(px, padT); + ctx.lineTo(px, padT + plotH); + ctx.strokeStyle = "#1b232c"; + ctx.stroke(); + ctx.fillStyle = "#5b6773"; + ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); + } + + const line = + view.status === "crashed" + ? "#e05a5a" + : view.status === "cashed" + ? "#4ec27b" + : "#e0b64a"; + + ctx.beginPath(); + const samples = 180; + for (let index = 0; index <= samples; index += 1) { + const seconds = (elapsed * index) / samples; + const value = Math.min(yMax, Math.exp(GROWTH * seconds)); + const px = x(seconds); + const py = y(value); + if (index === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.strokeStyle = line; + ctx.lineWidth = 2.4; + ctx.stroke(); + + const endX = x(elapsed); + const endY = y(Math.min(yMax, multiplier)); + ctx.beginPath(); + ctx.arc(endX, endY, 4.5, 0, Math.PI * 2); + ctx.fillStyle = line; + ctx.fill(); + + ctx.fillStyle = line; + ctx.font = "600 12px ui-monospace, monospace"; + ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10)); +} + +async function verifyReveal( + seed: string, + commit: string, + crashPoint: number, +): Promise<[boolean, boolean]> { + const bytes = new Uint8Array(seed.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16); + } + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const commitOk = actual === commit.toLowerCase(); + + let uniform = 0; + for (let index = 0; index < 8; index += 1) { + uniform = uniform * 256 + bytes[index]; + } + uniform /= 2 ** 64; + const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform))); + const pointOk = Math.abs(point - crashPoint) < 1e-6; + return [commitOk, pointOk]; +} diff --git a/web/play/src/dom.ts b/web/play/src/dom.ts new file mode 100644 index 0000000..d8ed212 --- /dev/null +++ b/web/play/src/dom.ts @@ -0,0 +1,104 @@ +export function byId(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error(`missing #${id}`); + return node as T; +} + +export function node( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +export function clear(element: HTMLElement): void { + element.replaceChildren(); +} + +export function groupDigits(amount: number): string { + const negative = amount < 0; + const digits = String(Math.abs(Math.trunc(amount))); + let grouped = ""; + for (let index = 0; index < digits.length; index += 1) { + if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009"; + grouped += digits[index]; + } + return negative ? `-${grouped}` : grouped; +} + +export function money(amount: number | null | undefined): string { + if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr"; + return `${groupDigits(amount)} Kr`; +} + +export function signedMoney(amount: number): string { + return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`; +} + +export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void { + const root = document.getElementById("toasts"); + if (!root || !text) return; + const item = node("div", `toast ${tone}`, text); + root.append(item); + window.setTimeout(() => { + item.style.opacity = "0"; + item.style.transition = "opacity 220ms ease"; + window.setTimeout(() => item.remove(), 240); + }, 4200); + while (root.children.length > 4) root.firstElementChild?.remove(); +} + +export function toneFor(text: string): "info" | "good" | "bad" { + const lowered = text.toLowerCase(); + if ( + text.startsWith("ERROR") || + lowered.includes("failed") || + lowered.includes("lost") || + lowered.includes("invalid") || + lowered.includes("forfeit") + ) { + return "bad"; + } + if ( + text.startsWith("Logged in") || + text.startsWith("Account created") || + lowered.includes("won") || + lowered.includes("cashed") + ) { + return "good"; + } + return "info"; +} + +export async function sha256Hex(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export function shortHash(hash: string, keep = 10): string { + if (hash.length <= keep * 2) return hash; + return `${hash.slice(0, keep)}…${hash.slice(-keep)}`; +} + +export function formatTime(raw: string): string { + // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC. + const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z"); + if (Number.isNaN(parsed)) return raw; + return new Date(parsed).toLocaleString(); +} + +export function cardFace(card: string): { rank: string; suit: string; red: boolean } { + if (card === "?" || !card) return { rank: "?", suit: "", red: false }; + const suit = card.slice(-1); + const rank = card.slice(0, -1); + const glyph = + suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit; + return { rank, suit: glyph, red: suit === "D" || suit === "H" }; +} diff --git a/web/play/src/history.ts b/web/play/src/history.ts new file mode 100644 index 0000000..9e7a564 --- /dev/null +++ b/web/play/src/history.ts @@ -0,0 +1,60 @@ +import type { View } from "./types"; +import { clear, formatTime, money, node, signedMoney } from "./dom"; + +function summary(record: any): string { + const details = record.details ?? {}; + switch (record.game) { + case "slots": + return String(details.win_type ?? "spin"); + case "roulette": + return `${details.number ?? "?"} ${details.color ?? ""}`.trim(); + case "blackjack": + return String(details.result ?? ""); + case "crash": { + const result = String(details.result ?? ""); + const point = details.crash_point; + return point === undefined || point === null ? result : `${result} @ ${Number(point).toFixed(2)}×`; + } + default: + return ""; + } +} + +export function renderHistory(root: HTMLElement, view: View): void { + clear(root); + const records: any[] = view.records ?? []; + if (records.length === 0) { + const empty = node("div", "board"); + empty.append( + node("h2", undefined, "No plays yet"), + node("p", "muted", "Your settled plays will appear here."), + ); + root.append(empty); + return; + } + + const wrap = node("div", "table-wrap"); + const table = node("table"); + const head = node("thead"); + const headRow = node("tr"); + for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) { + headRow.append(node("th", undefined, label)); + } + head.append(headRow); + const body = node("tbody"); + for (const record of records) { + const row = node("tr"); + row.append(node("td", "mono", formatTime(String(record.at ?? "")))); + row.append(node("td", undefined, String(record.game ?? ""))); + row.append(node("td", "num", money(Number(record.bet ?? 0)))); + row.append(node("td", "muted", summary(record))); + const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0); + const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net)); + row.append(netCell); + row.append(node("td", "num", money(Number(record.balance_after ?? 0)))); + body.append(row); + } + table.append(head, body); + wrap.append(table); + root.append(wrap); +} diff --git a/web/play/src/main.ts b/web/play/src/main.ts new file mode 100644 index 0000000..fd55250 --- /dev/null +++ b/web/play/src/main.ts @@ -0,0 +1,323 @@ +import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; +import { Gateway } from "./net"; +import { renderBlackjack } from "./blackjack"; +import { renderCrash } from "./crash"; +import { renderHistory } from "./history"; +import { renderRoulette } from "./roulette"; +import { renderSlots } from "./slots"; +import type { View } from "./types"; + +const authScreen = byId("auth-screen"); +const lobbyScreen = byId("lobby-screen"); +const gameScreen = byId("game-screen"); +const historyScreen = byId("history-screen"); +const sessionBar = byId("session-bar"); +const accountName = byId("account-name"); +const accountBalance = byId("account-balance"); +const gameGrid = byId("game-grid"); +const gameBoard = byId("game-board"); +const gameSide = byId("game-side"); +const gameTitle = byId("game-title"); +const gameBadge = byId("game-badge"); +const historyBody = byId("history-body"); +const authForm = byId("auth-form"); +const authUser = byId("auth-user"); +const authPass = byId("auth-pass"); +const authError = byId("auth-error"); +const authSubmit = byId("auth-submit"); +const tabLogin = byId("tab-login"); +const tabRegister = byId("tab-register"); +const reconnectOverlay = byId("reconnect-overlay"); +const reconnectMessage = byId("reconnect-message"); +const replacedOverlay = byId("replaced-overlay"); + +let view: View | null = null; +let currentUser: string | null = null; +let lastBalance: number | null = null; +let authMode: "login" | "register" = "login"; +let infoFor: string | null = null; +let serverOffset = 0; +let reconnectAttempts = 0; +const bets = new Map([ + ["slots", 10], + ["roulette", 10], + ["blackjack", 10], + ["crash", 10], +]); + +const titles: Record = { + slots: "Slots", + roulette: "Roulette", + blackjack: "Blackjack", + crash: "Crash", +}; + +const descriptions: Record = { + 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.", +}; + +const basePath = location.pathname.includes("/play/") + ? location.pathname.slice(0, location.pathname.indexOf("/play/")) + : ""; +const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; + +const gateway = new Gateway(wsUrl, { + onHello(hello) { + serverOffset = hello.time - Date.now(); + currentUser = hello.user ?? null; + gameBadge.textContent = `v${hello.version}`; + }, + onView(next) { + view = next; + // Game and history views carry no account field; the lobby is the source + // of truth for who is signed in. A null lobby user is a logout or an + // expired session. + if (typeof next.balance === "number") lastBalance = next.balance; + if (next.screen === "lobby" && "user" in next) { + currentUser = (next.user as string | null) ?? null; + if (currentUser) authPass.value = ""; + else lastBalance = null; + } + reconnectAttempts = 0; + reconnectOverlay.hidden = true; + render(); + if (currentUser && view.balance === null && infoFor !== currentUser) { + infoFor = currentUser; + gateway.send("Info"); + } + if (!currentUser) infoFor = null; + }, + onNotice(text) { + if ( + text.startsWith("Connected to:") || + text.startsWith("Fingerprint:") || + text.startsWith("User:") || + text.startsWith("Account:") || + text.startsWith("Balance:") + ) { + return; + } + toast(text, toneFor(text)); + if (text.startsWith("Account created")) { + setAuthMode("login"); + authPass.value = ""; + authUser.focus(); + } + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onError(text) { + toast(text, "bad"); + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onReplaced() { + replacedOverlay.hidden = false; + }, + onPong() { + /* The gateway answered; the socket is alive. */ + }, + onOpen() { + reconnectOverlay.hidden = true; + }, + onClosed(willRetry) { + if (replacedOverlay.hidden === false) return; + if (!willRetry) { + reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again."; + reconnectOverlay.hidden = false; + return; + } + reconnectAttempts += 1; + reconnectMessage.textContent = + reconnectAttempts > 1 + ? "Still trying to reach the game server…" + : "Trying to reach the game server…"; + reconnectOverlay.hidden = false; + }, +}); + +function send(command: unknown): void { + gateway.send(command); +} + +function serverNow(): number { + return Date.now() + serverOffset; +} + +function setAuthMode(mode: "login" | "register"): void { + authMode = mode; + tabLogin.classList.toggle("active", mode === "login"); + tabRegister.classList.toggle("active", mode === "register"); + authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; + authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; + authError.textContent = ""; +} + +function show(screen: "auth" | "lobby" | "game" | "history"): void { + authScreen.hidden = screen !== "auth"; + lobbyScreen.hidden = screen !== "lobby"; + gameScreen.hidden = screen !== "game"; + historyScreen.hidden = screen !== "history"; +} + +function setBalance(balance: number | null | undefined): void { + accountBalance.textContent = money(balance ?? null); +} + +function render(): void { + if (!view) return; + const user = currentUser; + if (user) { + sessionBar.hidden = false; + accountName.textContent = String(user); + } else { + sessionBar.hidden = true; + } + + setBalance(lastBalance); + + if (!user) { + show("auth"); + authError.textContent = view.login_error ? String(view.login_error) : ""; + if (view.screen === "lobby" && view.connected === false) { + authError.textContent = "The game server is not reachable yet. Retrying…"; + } + return; + } + + switch (view.screen) { + case "lobby": + show("lobby"); + renderLobby(view); + break; + case "history": + show("history"); + renderHistory(historyBody, view); + break; + case "slots": + case "roulette": + case "blackjack": + case "crash": + show("game"); + gameTitle.textContent = titles[view.screen] ?? view.screen; + gameBadge.textContent = badge(view); + if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); + else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); + else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); + else renderCrash(gameBoard, gameSide, view, send, { now: serverNow }); + break; + default: + show("auth"); + } +} + +function badge(view: View): string { + if (view.screen === "crash") return String(view.status ?? ""); + if (view.screen === "blackjack") return String(view.status ?? ""); + if (view.screen === "slots") return String(view.phase ?? ""); + if (view.screen === "roulette") return String(view.phase ?? ""); + return ""; +} + +function renderLobby(lobby: View): void { + gameGrid.replaceChildren(); + const list: [string, string][] = lobby.games ?? []; + for (const [id, name] of list) { + const card = node("article", `game-card ${id}`); + card.append(node("h3", undefined, name || titles[id] || id)); + card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); + + const row = node("div", "row wrap"); + const label = node("label", "field"); + label.style.flex = "1"; + label.append(node("span", "label", "Bet")); + const input = node("input", "input bet-input") as HTMLInputElement; + input.type = "number"; + input.min = "1"; + input.step = "10"; + input.value = String(bets.get(id) ?? 10); + input.addEventListener("change", () => { + const value = Math.max(1, Math.floor(Number(input.value) || 1)); + input.value = String(value); + bets.set(id, value); + }); + label.append(input); + + const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play"); + play.addEventListener("click", () => { + const bet = Math.max(1, Math.floor(Number(input.value) || 1)); + bets.set(id, bet); + send({ Play: { game: id, bet } }); + }); + row.append(label, play); + card.append(row); + gameGrid.append(card); + } + + if (list.length === 0) { + gameGrid.append(node("p", "muted", "The server did not advertise any games.")); + } +} + +// Auth tabs and form ------------------------------------------------------- +tabLogin.addEventListener("click", () => setAuthMode("login")); +tabRegister.addEventListener("click", () => setAuthMode("register")); + +authForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const user = authUser.value.trim(); + const password = authPass.value; + if (!user || !password) { + authError.textContent = "Enter a username and a password."; + return; + } + authSubmit.disabled = true; + authSubmit.textContent = "Working…"; + authError.textContent = ""; + try { + const credential = await sha256Hex(password); + if (authMode === "login") { + send({ Login: { user, credential } }); + } else { + send({ Register: { user, credential } }); + } + } catch { + authError.textContent = "This browser cannot hash the password (needs a secure context)."; + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + } +}); + +// Chrome and navigation ---------------------------------------------------- +byId("logout").addEventListener("click", () => send("Logout")); +byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); +byId("refresh-lobby").addEventListener("click", () => send("Info")); +byId("history-back").addEventListener("click", () => send("ShowLobby")); +byId("leave-game").addEventListener("click", () => { + if (!view) return; + const live = + (view.screen === "blackjack" && view.status === "player") || + (view.screen === "crash" && view.status === "running"); + if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return; + send("Leave"); +}); +byId("reload-replaced").addEventListener("click", () => location.reload()); +byId("reload-reconnect").addEventListener("click", () => location.reload()); + +window.addEventListener("resize", () => { + window.clearTimeout((window as any).__resizeTimer); + (window as any).__resizeTimer = window.setTimeout(() => { + if (view) render(); + }, 140); +}); + +// A periodic ping keeps the gateway session visibly alive and refreshes the +// server clock estimate without changing game state. +window.setInterval(() => { + if (currentUser) gateway.ping(); +}, 25_000); + +gateway.connect(); diff --git a/web/play/src/net.ts b/web/play/src/net.ts new file mode 100644 index 0000000..0a963ba --- /dev/null +++ b/web/play/src/net.ts @@ -0,0 +1,120 @@ +import type { Handlers } from "./types"; + +const MAX_ATTEMPTS = 6; + +/** + * Browser side of the gateway socket. + * + * The cookie is HttpOnly and handled by the browser; this class never reads or + * stores a credential. A transient close reconnects inside the gateway's grace + * window; a deliberate takeover stops for good. + */ +export class Gateway { + private socket: WebSocket | null = null; + private stopped = false; + private replaced = false; + private attempts = 0; + private timer: number | undefined; + + constructor( + private readonly url: string, + private readonly handlers: Handlers, + ) {} + + connect(): void { + if (this.stopped) return; + this.clearTimer(); + + let socket: WebSocket; + try { + socket = new WebSocket(this.url); + } catch { + const retrying = this.scheduleReconnect(); + if (!retrying) this.handlers.onClosed(false); + return; + } + this.socket = socket; + + socket.addEventListener("open", () => { + this.attempts = 0; + this.handlers.onOpen(); + }); + + socket.addEventListener("message", (event) => { + let message: any; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + switch (message.type) { + case "hello": + this.handlers.onHello(message); + break; + case "view": + this.handlers.onView(message.view); + break; + case "notice": + this.handlers.onNotice(String(message.text ?? "")); + break; + case "error": + this.handlers.onError(String(message.text ?? "")); + break; + case "replaced": + this.replaced = true; + this.handlers.onReplaced(); + break; + case "pong": + this.handlers.onPong(); + break; + } + }); + + socket.addEventListener("close", () => { + const wasThisSocket = this.socket === socket; + if (wasThisSocket) this.socket = null; + if (this.replaced || this.stopped) { + this.handlers.onClosed(false); + return; + } + const retrying = this.scheduleReconnect(); + this.handlers.onClosed(retrying); + }); + + socket.addEventListener("error", () => { + // The close event follows; reconnect is scheduled there. + }); + } + + send(command: unknown): void { + if (this.socket?.readyState !== WebSocket.OPEN) return; + this.socket.send(JSON.stringify({ type: "command", command })); + } + + ping(): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type: "ping" })); + } + } + + close(): void { + this.stopped = true; + this.clearTimer(); + this.socket?.close(); + } + + private scheduleReconnect(): boolean { + if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false; + this.attempts += 1; + const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000); + this.timer = window.setTimeout(() => this.connect(), delay); + return true; + } + + private clearTimer(): void { + if (this.timer !== undefined) { + window.clearTimeout(this.timer); + this.timer = undefined; + } + } +} diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts new file mode 100644 index 0000000..cbb7c6e --- /dev/null +++ b/web/play/src/roulette.ts @@ -0,0 +1,474 @@ +import type { View } from "./types"; +import { clear, money, node } from "./dom"; + +type Zone = { + x: number; + y: number; + w: number; + h: number; + target: any; + label: string; + kind: "number" | "zero" | "dozen" | "column" | "outside"; + number?: number; +}; + +type Hotspot = { + x: number; + y: number; + radius: number; + target: any; + rank: number; +}; + +type Layout = { + width: number; + height: number; + cellW: number; + cellH: number; + zeroX: number; + zeroW: number; + gridX: number; + gridY: number; + gridW: number; + columnX: number; + dozensY: number; + outsideY: number; + outsideH: number; + zones: Zone[]; + hotspots: Hotspot[]; +}; + +const RED = "#b0313a"; +const BLACK = "#151b21"; +const ZERO = "#106b45"; +const FELT = "#0d5c39"; +const FELT_DARK = "#09472c"; +const FELT_LINE = "#1f7d52"; +const TEXT = "#ecf7f0"; +const GOLD = "#e0b64a"; + +function colorOf(number: number): string { + if (number === 0) return "green"; + const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; + return reds.includes(number) ? "red" : "black"; +} + +function layout(width: number, height: number): Layout { + const pad = 14; + const zeroW = Math.max(38, width * 0.055); + const columnW = Math.max(34, width * 0.05); + const outsideH = Math.max(30, height * 0.07); + const gap = 6; + const gridW = width - pad * 2 - zeroW - columnW; + const cellW = gridW / 12; + const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; + const gridX = pad + zeroW; + const gridY = pad; + const dozensY = gridY + cellH * 3 + gap; + const outsideY = dozensY + outsideH + gap; + const zones: Zone[] = []; + + for (let column = 0; column < 12; column += 1) { + for (let row = 0; row < 3; row += 1) { + const number = column * 3 + (3 - row); + zones.push({ + x: gridX + column * cellW, + y: gridY + row * cellH, + w: cellW, + h: cellH, + target: { bet: "straight", number }, + label: String(number), + kind: "number", + number, + }); + } + } + + zones.push({ + x: pad, + y: gridY, + w: zeroW, + h: cellH * 3, + target: { bet: "straight", number: 0 }, + label: "0", + kind: "zero", + number: 0, + }); + + for (let top = 0; top < 3; top += 1) { + const index = 2 - top; + zones.push({ + x: gridX + gridW, + y: gridY + top * cellH, + w: columnW, + h: cellH, + target: { bet: "column", index }, + label: "2:1", + kind: "column", + }); + } + + for (let index = 0; index < 3; index += 1) { + zones.push({ + x: gridX + (gridW / 3) * index, + y: dozensY, + w: gridW / 3, + h: outsideH, + target: { bet: "dozen", index }, + label: ["1st 12", "2nd 12", "3rd 12"][index], + kind: "dozen", + }); + } + + const outside = [ + { 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" }, + ]; + outside.forEach((item, index) => { + zones.push({ + x: gridX + (gridW / 6) * index, + y: outsideY, + w: gridW / 6, + h: outsideH, + target: { bet: "outside", kind: item.kind }, + label: item.label, + kind: "outside", + }); + }); + + // Split and corner hotspots sit on the lines between cells, like the real + // felt (and the desktop fine lattice). Corners rank ahead of splits. + const hotspots: Hotspot[] = []; + const cellCenter = (number: number) => { + const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); + return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null; + }; + + for (let column = 0; column < 11; column += 1) { + for (let row = 0; row < 2; row += 1) { + const top = column * 3 + (3 - row); + hotspots.push({ + x: gridX + (column + 1) * cellW, + y: gridY + (row + 1) * cellH, + radius: 12, + rank: 0, + target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] }, + }); + } + } + + for (let column = 0; column < 12; column += 1) { + for (let row = 0; row < 2; row += 1) { + const top = column * 3 + (3 - row); + const center = cellCenter(top); + if (!center) continue; + hotspots.push({ + x: center.x, + y: gridY + (row + 1) * cellH, + radius: 10, + rank: 1, + target: { bet: "split", a: top, b: top - 1 }, + }); + } + } + + for (let column = 0; column < 11; column += 1) { + for (let row = 0; row < 3; row += 1) { + const left = column * 3 + (3 - row); + hotspots.push({ + x: gridX + (column + 1) * cellW, + y: gridY + row * cellH + cellH / 2, + radius: 10, + rank: 1, + target: { bet: "split", a: left, b: left + 3 }, + }); + } + } + + for (let row = 0; row < 3; row += 1) { + const number = 3 - row; + hotspots.push({ + x: gridX, + y: gridY + row * cellH + cellH / 2, + radius: 10, + rank: 1, + target: { bet: "split", a: 0, b: number }, + }); + } + + return { + width, + height, + cellW, + cellH, + zeroX: pad, + zeroW, + gridX, + gridY, + gridW, + columnX: gridX + gridW, + dozensY, + outsideY, + outsideH, + zones, + hotspots, + }; +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radius: number, +): void { + const r = Math.min(radius, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function draw(canvas: HTMLCanvasElement, view: View): Layout { + const width = canvas.clientWidth || 800; + const height = canvas.clientHeight || 540; + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + const ctx = canvas.getContext("2d"); + const map = layout(width, height); + if (!ctx) return map; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = FELT_DARK; + roundRect(ctx, 0, 0, width, height, 10); + ctx.fill(); + + for (const zone of map.zones) { + let fill = FELT; + if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; + else if (zone.kind === "zero") fill = ZERO; + ctx.fillStyle = fill; + ctx.strokeStyle = FELT_LINE; + ctx.lineWidth = 1; + roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = TEXT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + if (zone.kind === "number" || zone.kind === "zero") { + ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; + } else { + ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; + } + ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); + } + + drawChips(ctx, map, view); + return map; +} + +function targetKey(target: any): string { + return JSON.stringify(target); +} + +function centerFor(layout: Layout, target: any): { x: number; y: number } | null { + const key = targetKey(target); + const exact = layout.zones.find((zone) => targetKey(zone.target) === key); + if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; + + // Split and corner bets are reachable from the desktop's fine lattice. A + // browser session can still show them: average the covered number cells. + if (target?.bet === "split" || target?.bet === "corner") { + const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; + const cells = layout.zones.filter( + (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), + ); + if (cells.length > 0) { + return { + x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length, + y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length, + }; + } + } + return null; +} + +function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void { + const chips: any[] = view.bets?.chips ?? []; + for (const chip of chips) { + const center = centerFor(map, chip.target); + if (!center) continue; + const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); + ctx.beginPath(); + ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); + ctx.fillStyle = GOLD; + ctx.fill(); + ctx.strokeStyle = "#7a5a12"; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.fillStyle = "#221a05"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; + ctx.fillText(String(chip.amount), center.x, center.y + 0.5); + } +} + +export function renderRoulette( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + clear(board); + clear(side); + + const spinning = view.phase === "spinning"; + board.append(node("h2", undefined, "Roulette")); + board.append( + node( + "p", + "note", + "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.", + ), + ); + + const wrap = node("div", "roulette-wrap"); + const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; + wrap.append(canvas); + + if (view.phase === "result" && view.number !== null && view.number !== undefined) { + const result = node("div", `wheel-result ${view.color ?? ""}`); + result.append(node("div", "label", "Result")); + result.append(node("div", "num", String(view.number))); + const winnings = Number(view.winnings ?? 0); + result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); + wrap.append(result); + } + board.append(wrap); + + // The canvas must be in the document before its client size is read. + const map = draw(canvas, view); + let current = map; + + const hit = (clientX: number, clientY: number): Zone | null => { + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + let best: Hotspot | null = null; + for (const hotspot of current.hotspots) { + const distance = Math.hypot(x - hotspot.x, y - hotspot.y); + if (distance > hotspot.radius) continue; + if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { + best = hotspot; + } + } + if (best) { + return { + x: best.x - best.radius, + y: best.y - best.radius, + w: best.radius * 2, + h: best.radius * 2, + target: best.target, + label: "", + kind: "number", + }; + } + + for (let index = current.zones.length - 1; index >= 0; index -= 1) { + const zone = current.zones[index]; + if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) { + return zone; + } + } + return null; + }; + + canvas.addEventListener("click", (event) => { + if (spinning) return; + const zone = hit(event.clientX, event.clientY); + if (zone) send({ PlaceBetAt: zone.target }); + }); + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + if (spinning) return; + const zone = hit(event.clientX, event.clientY); + if (zone) send({ RemoveBetAt: zone.target }); + }); + canvas.addEventListener("mousemove", (event) => { + const zone = hit(event.clientX, event.clientY); + canvas.style.cursor = zone && !spinning ? "pointer" : "default"; + }); + new ResizeObserver(() => { + current = draw(canvas, view); + }).observe(canvas); + + // Sidebar + side.append(node("div", "label", "Roulette")); + side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); + + side.append(node("div", "label", "Chip")); + const chipRow = node("div", "chip-row"); + for (const value of [10, 25, 50, 100, 500]) { + const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); + button.disabled = spinning; + button.addEventListener("click", () => send({ SetChip: value })); + chipRow.append(button); + } + side.append(chipRow); + + const total = (view.bets?.chips ?? []).reduce( + (sum: number, chip: any) => sum + Number(chip.amount ?? 0), + 0, + ); + const totalRow = node("div", "stat"); + totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); + side.append(totalRow); + + if (view.balance !== null && view.balance !== undefined) { + const balanceRow = node("div", "stat"); + balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); + side.append(balanceRow); + } + + if (view.error) side.append(node("p", "error-text", String(view.error))); + + const status = node("p", "note"); + if (spinning) status.textContent = "Spinning…"; + else if (view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; + status.className = winnings > 0 ? "note win-text" : "note"; + } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; + side.append(status); + + const actions = node("div", "actions"); + const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); + spin.disabled = spinning || total === 0; + spin.addEventListener("click", () => send("Spin")); + const clearBets = node("button", "btn wide", "Clear bets"); + clearBets.disabled = spinning || total === 0; + clearBets.addEventListener("click", () => send("ClearBets")); + const leave = node("button", "btn ghost wide", "Leave table"); + leave.disabled = spinning; + leave.addEventListener("click", () => { + if (spinning) return; + send("Leave"); + }); + actions.append(spin, clearBets, leave); + side.append(actions); +} diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts new file mode 100644 index 0000000..dba1e25 --- /dev/null +++ b/web/play/src/slots.ts @@ -0,0 +1,144 @@ +import type { View } from "./types"; +import { clear, money, node } from "./dom"; + +const REELS = 5; +const ROWS = 3; +const SYMBOLS = "CLOGDS"; +const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; + +let spinUntil = 0; +let animation: number | undefined; +let latest: View | null = null; +let latestBoard: HTMLElement | null = null; + +export function requestSlotsSpin(): void { + spinUntil = Date.now() + 900; +} + +function spinning(view: View): boolean { + return view.phase === "spinning" || Date.now() < spinUntil; +} + +export function renderSlots( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + latest = view; + latestBoard = board; + if (animation !== undefined) { + window.clearInterval(animation); + animation = undefined; + } + + const active = spinning(view); + clear(board); + clear(side); + + const title = node("h2", undefined, "Slots"); + const note = node("p", "note", "Match three or more symbols from the left. Five paylines."); + board.append(title, note); + + const reels = node("div", "reels"); + const tiles: HTMLElement[] = []; + const flat = String(view.reels ?? ""); + for (let column = 0; column < REELS; column += 1) { + const reel = node("div", "reel"); + for (let row = 0; row < ROWS; row += 1) { + const index = column * ROWS + row; + const symbol = active + ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length] + : flat.length === REELS * ROWS + ? flat[index] + : flat.length === REELS + ? flat[column] + : SYMBOLS[(index * 5 + column) % SYMBOLS.length]; + const tile = node("div", "tile", symbol); + tile.classList.add(`sym-${symbol}`); + if (active) tile.classList.add("spinning"); + if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win"); + tiles.push(tile); + reel.append(tile); + } + reels.append(reel); + } + board.append(reels); + + const lines = node("div", "win-lines"); + if (!active && view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + if (winnings > 0) { + const names = (view.win_lines ?? []) + .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) + .join(" · "); + lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; + lines.classList.add("win-text"); + } else { + lines.textContent = "No win — spin again."; + } + } else if (active) { + lines.textContent = "Spinning…"; + } else { + lines.textContent = "Pick a bet and spin."; + } + board.append(lines); + + if (active) { + let tick = 0; + animation = window.setInterval(() => { + tick += 1; + for (let index = 0; index < tiles.length; index += 1) { + const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length]; + tiles[index].textContent = symbol; + tiles[index].className = `tile spinning sym-${symbol}`; + } + if (Date.now() >= spinUntil && latest && latestBoard) { + window.clearInterval(animation); + animation = undefined; + renderSlots(latestBoard, side, latest, send); + } + }, 70); + } + + // Sidebar + side.append(node("div", "label", "Slots")); + side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); + + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10"); + const bet = node("strong", "mono", money(Number(view.bet ?? 0))); + const plus = node("button", "btn small", "+10"); + minus.disabled = active; + plus.disabled = active; + minus.addEventListener("click", () => send({ AdjustBet: -10 })); + plus.addEventListener("click", () => send({ AdjustBet: 10 })); + betRow.append(minus, bet, plus); + side.append(node("div", "label", "Bet"), betRow); + + if (view.error) { + side.append(node("p", "error-text", String(view.error))); + } + + const status = node("p", "note"); + if (active) status.textContent = "Spinning…"; + else if (view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; + if (winnings > 0) status.classList.add("win-text"); + } else status.textContent = "Ready when you are."; + side.append(status); + + const actions = node("div", "actions"); + const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN"); + spin.disabled = active; + spin.addEventListener("click", () => { + requestSlotsSpin(); + send("Spin"); + }); + const leave = node("button", "btn ghost wide", "Leave machine"); + leave.disabled = active; + leave.addEventListener("click", () => send("Leave")); + actions.append(spin, leave); + side.append(actions); +} diff --git a/web/play/src/types.ts b/web/play/src/types.ts new file mode 100644 index 0000000..e594a0b --- /dev/null +++ b/web/play/src/types.ts @@ -0,0 +1,27 @@ +export type View = { + screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash"; + [key: string]: any; +}; + +export type Hello = { + type: "hello"; + version: string; + grace_seconds: number; + /** Signed-in user restored after a refresh, if any. */ + user?: string | null; + /** Estimated game-server Unix time in milliseconds. */ + time: number; +}; + +export type Send = (command: unknown) => void; + +export type Handlers = { + onHello: (hello: Hello) => void; + onView: (view: View) => void; + onNotice: (text: string) => void; + onError: (text: string) => void; + onReplaced: () => void; + onPong: () => void; + onOpen: () => void; + onClosed: (willRetry: boolean) => void; +}; diff --git a/web/play/style.css b/web/play/style.css new file mode 100644 index 0000000..20842d2 --- /dev/null +++ b/web/play/style.css @@ -0,0 +1,407 @@ +:root { + --bg: #0a0e12; + --surface: #11171d; + --raised: #171f27; + --hover: #1d2731; + --line: #242e38; + --text: #d9e2ea; + --dim: #8a98a6; + --faint: #5b6773; + --accent: #4fb8c9; + --win: #4ec27b; + --loss: #e05a5a; + --gold: #e0b64a; + --felt: #0d5c39; + --felt-dark: #09472c; + --felt-line: #1f7d52; + --felt-text: #ecf7f0; + --red: #b0313a; + --black: #151b21; + --zero: #106b45; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --sans: "Open Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: "Hack", ui-monospace, "SFMono-Regular", "Cascadia Code", monospace; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + background: + radial-gradient(1100px 600px at 80% -10%, rgba(79, 184, 201, 0.06), transparent 60%), + var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +[hidden] { display: none !important; } + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +button, input { font: inherit; color: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: 0.45; } + +.shell { min-height: 100%; display: flex; flex-direction: column; } + +.topbar { + display: flex; + align-items: center; + gap: 16px; + min-height: 64px; + padding: 0 24px; + border-bottom: 1px solid var(--line); + background: rgba(10, 14, 18, 0.86); + backdrop-filter: blur(10px); + position: sticky; + top: 0; + z-index: 20; +} +.brand { + color: var(--text); + font-family: var(--mono); + font-size: 19px; + letter-spacing: 0.04em; + white-space: nowrap; +} +.brand span { color: var(--win); } +.brand:hover { text-decoration: none; } +.tagline { + color: var(--faint); + font-family: var(--mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; +} +.spacer { flex: 1; } +.session { display: flex; align-items: center; gap: 12px; } +.session .who { color: var(--dim); font-family: var(--mono); font-size: 12px; } +.session .who strong { color: var(--text); font-weight: 600; } +.balance { + color: var(--win); + font-family: var(--mono); + font-size: 16px; + padding: 4px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + white-space: nowrap; +} + +main { width: min(1240px, 100%); margin: 0 auto; padding: 28px 24px 64px; flex: 1; } + +h1, h2, h3 { margin: 0; font-weight: 600; } +h1 { font-size: 26px; letter-spacing: -0.01em; } +h2 { font-size: 18px; } +.muted { color: var(--dim); } +.faint { color: var(--faint); } +.mono { font-family: var(--mono); } +.label { + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--dim); +} +.error-text { color: var(--loss); } +.win-text { color: var(--win); } + +.btn { + border: 1px solid var(--line); + background: var(--raised); + color: var(--text); + padding: 9px 14px; + border-radius: var(--radius-md); + line-height: 1.2; + transition: background 120ms ease, border-color 120ms ease, transform 80ms ease; +} +.btn:hover:not(:disabled) { background: var(--hover); border-color: #33404d; } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn.primary { + background: var(--win); + border-color: var(--win); + color: #06120b; + font-weight: 700; +} +.btn.primary:hover:not(:disabled) { filter: brightness(1.08); } +.btn.accent { + background: rgba(79, 184, 201, 0.14); + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} +.btn.danger { + background: rgba(224, 90, 90, 0.12); + border-color: rgba(224, 90, 90, 0.55); + color: var(--loss); +} +.btn.ghost { background: transparent; } +.btn.small { padding: 5px 9px; font-size: 12px; } +.btn.wide { width: 100%; } +.btn.big { padding: 13px 18px; font-size: 15px; } + +.input { + width: 100%; + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 10px 12px; + outline: none; +} +.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 184, 201, 0.18); } +.field { display: grid; gap: 6px; } +.row { display: flex; align-items: center; gap: 10px; } +.row.wrap { flex-wrap: wrap; } +.hr { height: 1px; background: var(--line); border: 0; margin: 18px 0; } + +/* Auth */ +.auth-wrap { display: grid; place-items: center; min-height: 52vh; } +.auth-card { + width: min(430px, 100%); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 26px; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35); +} +.auth-tabs { display: flex; gap: 4px; margin-bottom: 20px; background: var(--bg); padding: 4px; border-radius: var(--radius-md); } +.auth-tab { flex: 1; border: 0; background: transparent; color: var(--dim); padding: 8px; border-radius: var(--radius-sm); } +.auth-tab.active { background: var(--raised); color: var(--text); } +.auth-card form { display: grid; gap: 14px; } +.auth-foot { margin-top: 16px; font-size: 12px; color: var(--faint); } + +/* Lobby */ +.lobby-head { display: flex; align-items: end; gap: 18px; margin-bottom: 22px; flex-wrap: wrap; } +.lobby-head .spacer { flex: 1; } +.game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } +.game-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 178px; + position: relative; + overflow: hidden; +} +.game-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--accent); + opacity: 0.7; +} +.game-card.crash::before { background: var(--gold); } +.game-card.roulette::before { background: var(--felt-line); } +.game-card.blackjack::before { background: var(--loss); } +.game-card h3 { font-size: 17px; } +.game-card p { margin: 0; color: var(--dim); font-size: 13px; flex: 1; } +.game-card .row { justify-content: space-between; } +.bet-input { width: 92px; } + +/* Game shell */ +.game-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; } +.game-head .spacer { flex: 1; } +.game-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 16px; + align-items: start; +} +.board { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 18px; + min-height: 560px; + overflow: hidden; +} +.sidebar { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + position: sticky; + top: 84px; +} +.sidebar .actions { margin-top: auto; display: grid; gap: 8px; } +.sidebar .stat { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; } +.sidebar .stat strong { font-family: var(--mono); font-weight: 600; } +.note { color: var(--dim); font-size: 12px; } +.chip-row { display: flex; flex-wrap: wrap; gap: 6px; } +.chip-btn { + font-family: var(--mono); + font-size: 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--raised); + padding: 5px 10px; +} +.chip-btn.active { border-color: var(--gold); color: var(--gold); background: rgba(224, 182, 74, 0.1); } + +/* Slots */ +.reels { + display: grid; + grid-template-columns: repeat(5, minmax(60px, 1fr)); + gap: 10px; + max-width: 760px; + margin: 24px auto 0; +} +.reel { display: grid; gap: 8px; } +.tile { + aspect-ratio: 1 / 0.92; + display: grid; + place-items: center; + border-radius: var(--radius-md); + border: 1px solid var(--line); + background: var(--bg); + font-family: var(--mono); + font-size: clamp(26px, 5vw, 54px); + font-weight: 700; + color: var(--dim); + transition: color 90ms ease, border-color 90ms ease, background 90ms ease; +} +.tile.sym-C { color: #c8d1da; } +.tile.sym-L { color: #7cc4d4; } +.tile.sym-O { color: #d8c27a; } +.tile.sym-G { color: #83d09a; } +.tile.sym-D { color: #d48d8d; } +.tile.sym-S { color: #e0b64a; } +.tile.win { border-color: var(--win); background: rgba(78, 194, 123, 0.12); color: var(--win); } +.tile.spinning { color: var(--faint); } +.win-lines { text-align: center; margin-top: 18px; color: var(--dim); font-size: 12px; min-height: 18px; } + +/* Cards */ +.hand { margin: 16px 0; } +.hand .label { margin-bottom: 8px; } +.cards { display: flex; gap: 8px; flex-wrap: wrap; min-height: 116px; } +.card { + width: 76px; + height: 108px; + border-radius: 9px; + background: #f2f5f7; + color: #121820; + border: 1px solid #cfd8df; + padding: 8px; + display: flex; + flex-direction: column; + justify-content: space-between; + font-family: var(--mono); + font-weight: 700; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22); +} +.card.red { color: #a51f2d; } +.card.back { background: repeating-linear-gradient(135deg, #1d2731, #1d2731 6px, #26333f 6px, #26333f 12px); border-color: #33404d; } +.card .rank { font-size: 22px; line-height: 1; } +.card .suit { font-size: 22px; align-self: flex-end; } +.card.back .rank, .card.back .suit { color: var(--faint); } +.total { color: var(--dim); font-family: var(--mono); font-size: 12px; } +.total strong { color: var(--text); font-size: 15px; } +.outcome { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); } + +/* Crash */ +.crash-wrap { position: relative; } +.crash-number { + font-family: var(--mono); + font-size: clamp(44px, 9vw, 86px); + line-height: 1; + letter-spacing: -0.04em; +} +.crash-number.running { color: var(--gold); } +.crash-number.crashed { color: var(--loss); } +.crash-number.cashed { color: var(--win); } +.crash-meta { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px; color: var(--faint); font-size: 11px; font-family: var(--mono); } +.verify { margin-top: 12px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--bg); font-size: 11px; color: var(--dim); word-break: break-all; } +.verify.ok { border-color: rgba(78, 194, 123, 0.4); color: var(--win); } +.verify.bad { border-color: rgba(224, 90, 90, 0.5); color: var(--loss); } + +/* Roulette */ +.roulette-wrap { position: relative; } +canvas { display: block; width: 100%; } +.roulette-canvas { height: 540px; } +.crash-canvas { height: 360px; } +.wheel-result { + position: absolute; + top: 16px; + right: 16px; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--line); + background: rgba(10, 14, 18, 0.86); + font-family: var(--mono); +} +.wheel-result .num { font-size: 26px; font-weight: 700; } +.wheel-result.red .num { color: #f06a74; } +.wheel-result.black .num { color: #dfe8ef; } +.wheel-result.green .num { color: #66d49a; } + +/* History */ +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-lg); } +table { width: 100%; border-collapse: collapse; background: var(--surface); } +th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: 13px; white-space: nowrap; } +th { color: var(--dim); font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; } +tr:last-child td { border-bottom: 0; } +td.num { font-family: var(--mono); } +td.pos { color: var(--win); } +td.neg { color: var(--loss); } + +/* Toasts and overlays */ +#toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; width: min(380px, calc(100vw - 36px)); } +.toast { + background: var(--surface); + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: var(--radius-md); + padding: 10px 12px; + font-size: 13px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + animation: toast-in 160ms ease-out; +} +.toast.good { border-left-color: var(--win); } +.toast.bad { border-left-color: var(--loss); } +@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } } +.overlay { + position: fixed; + inset: 0; + background: rgba(5, 8, 11, 0.78); + display: grid; + place-items: center; + z-index: 60; + padding: 24px; +} +.modal { + width: min(440px, 100%); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 24px; + text-align: center; +} +.modal p { color: var(--dim); } + +@media (max-width: 900px) { + .game-body { grid-template-columns: 1fr; } + .sidebar { position: static; } + .tagline { display: none; } +} +@media (max-width: 620px) { + main { padding: 18px 14px 54px; } + .topbar { padding: 0 14px; gap: 10px; } + .session .who { display: none; } + .balance { font-size: 14px; } + .reels { gap: 6px; } + .tile { font-size: clamp(18px, 7vw, 34px); } + .card { width: 58px; height: 84px; } + .crash-canvas { height: 260px; } + .roulette-canvas { height: 400px; } +} diff --git a/web/play/test/smoke.mjs b/web/play/test/smoke.mjs new file mode 100644 index 0000000..c1274bc --- /dev/null +++ b/web/play/test/smoke.mjs @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import { webcrypto } from "node:crypto"; +import { JSDOM, VirtualConsole } from "jsdom"; + +const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8") + .replace('', ""); +const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8"); + +const failures = []; +const virtualConsole = new VirtualConsole(); +virtualConsole.on("jsdomError", (error) => failures.push(String(error))); + +const dom = new JSDOM(html, { + url: "http://127.0.0.1:8899/play/", + runScripts: "outside-only", + pretendToBeVisual: true, + virtualConsole, +}); +const { window } = dom; + +Object.defineProperty(window, "crypto", { value: webcrypto }); +window.devicePixelRatio = 1; +window.confirm = () => true; + +class MockWebSocket { + static OPEN = 1; + static instances = []; + readyState = MockWebSocket.OPEN; + sent = []; + listeners = new Map(); + + constructor(url) { + this.url = url; + MockWebSocket.instances.push(this); + queueMicrotask(() => this.emit("open", {})); + } + + addEventListener(type, listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + send(raw) { + this.sent.push(JSON.parse(raw)); + } + + close() { + this.emit("close", {}); + } + + emit(type, event) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + message(message) { + this.emit("message", { data: JSON.stringify(message) }); + } +} +window.WebSocket = MockWebSocket; +window.ResizeObserver = class { + observe() {} + disconnect() {} +}; +window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0); +window.cancelAnimationFrame = (handle) => window.clearTimeout(handle); + +const context = new Proxy( + {}, + { + get(target, key) { + if (key === "measureText") return () => ({ width: 10 }); + if (key in target) return target[key]; + return () => undefined; + }, + set(target, key, value) { + target[key] = value; + return true; + }, + }, +); +window.HTMLCanvasElement.prototype.getContext = function getContext() { + return context; +}; + +window.eval(bundle); + +const document = window.document; +const byId = (id) => document.getElementById(id); +const assert = (condition, message) => { + if (!condition) failures.push(message); +}; + +const socket = MockWebSocket.instances[0]; +assert(socket, "the app opened a WebSocket"); +assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`); + +const hello = { + type: "hello", + version: "5.0.0", + grace_seconds: 60, + time: Date.now(), +}; +const lobby = { + type: "view", + view: { + screen: "lobby", + connected: true, + user: null, + account: null, + balance: null, + games: [ + ["blackjack", "Blackjack"], + ["crash", "Crash"], + ["roulette", "Roulette"], + ["slots", "Slots"], + ], + login_error: null, + }, +}; +socket.message(hello); +socket.message(lobby); + +assert(byId("auth-screen").hidden === false, "auth is shown before login"); +assert(byId("session-bar").hidden === true, "no session bar before login"); + +byId("auth-user").value = "alice"; +byId("auth-pass").value = "secret"; +byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); + +await new Promise((resolve) => setTimeout(resolve, 10)); +const login = socket.sent.find((message) => message.command?.Login); +assert(login, "the login form sends a Login command"); +assert(login.command.Login.user === "alice", "the username is sent"); +assert( + /^[0-9a-f]{64}$/.test(login.command.Login.credential), + "the password is sent as a SHA-256 hex digest", +); + +socket.message({ + type: "view", + view: { + screen: "lobby", + connected: true, + user: "alice", + account: "1", + balance: null, + games: lobby.view.games, + login_error: null, + }, +}); +assert( + socket.sent.some((message) => message.command === "Info"), + "the app asks for the balance after login", +); +socket.message({ + type: "view", + view: { + screen: "lobby", + connected: true, + user: "alice", + account: "1", + balance: 1000, + games: lobby.view.games, + login_error: null, + }, +}); +assert(byId("auth-screen").hidden === true, "auth is hidden after login"); +assert(byId("lobby-screen").hidden === false, "the lobby is shown after login"); +assert(byId("account-name").textContent === "alice", "the account name is shown"); +assert(byId("account-balance").textContent.includes("1"), "the balance is shown"); +assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render"); +socket.message({ + type: "view", + view: { + screen: "slots", + bet: 10, + balance: 1000, + reels: "CLOGDSCLOGDSLLO".split(""), + win_cells: [0, 1, 2], + win_lines: [1], + win_type: "win", + winnings: 20, + phase: "result", + error: null, + }, +}); +assert(byId("game-screen").hidden === false, "the game screen is shown"); +assert(document.querySelectorAll(".reel").length === 5, "five reels render"); +assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render"); +assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action"); + +socket.message({ + type: "view", + view: { + screen: "roulette", + bet_amount: 10, + balance: 1000, + chip: 25, + bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] }, + cursor: { Lattice: { x: 1, y: 5 } }, + phase: "result", + number: 5, + color: "red", + winnings: 900, + error: null, + }, +}); +assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders"); +assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders"); +assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders"); + +socket.message({ + type: "view", + view: { + screen: "blackjack", + status: "player", + balance: 990, + bet: 10, + hand: { + session_id: "bj1", + phase: "player", + player: ["10S", "6H"], + dealer: ["9D", "?"], + player_total: 16, + dealer_total: 9, + bet: 10, + base_bet: 10, + balance: 990, + actions: ["hit", "stand", "double"], + result: "playing", + winnings: 0, + }, + error: null, + }, +}); +assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render"); +assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render"); +assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase"); +assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders"); + +socket.message({ + type: "view", + view: { + screen: "crash", + status: "running", + balance: 990, + bet: 10, + multiplier: 1.2, + potential: 12, + crash_point: null, + payout: 0, + started_at: Date.now(), + commit: "ab".repeat(32), + seed: null, + error: null, + }, +}); +assert(document.querySelector(".crash-canvas"), "the crash canvas renders"); +assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders"); +assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running"); +assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment"); + +socket.message({ + type: "view", + view: { + screen: "history", + records: [ + { + game: "slots", + bet: 10, + winnings: 20, + balance_after: 1010, + at: "2026-11-08 03:00:00", + details: { win_type: "win" }, + }, + ], + }, +}); +assert(byId("history-screen").hidden === false, "the history screen renders"); +assert(document.querySelector("table tbody tr"), "history rows render"); + +socket.message({ type: "replaced" }); +assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab"); + +await new Promise((resolve) => setTimeout(resolve, 30)); +dom.window.close(); + +if (failures.length > 0) { + console.error("web smoke test failed:"); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log("web smoke test passed"); diff --git a/web/play/tsconfig.json b/web/play/tsconfig.json new file mode 100644 index 0000000..9814d9c --- /dev/null +++ b/web/play/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} -- cgit v1.2.3