summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /web
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz
zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip
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.
Diffstat (limited to 'web')
-rw-r--r--web/README.md27
-rw-r--r--web/index.php1
-rw-r--r--web/nginx.conf.example25
-rw-r--r--web/play/.gitignore2
-rw-r--r--web/play/README.md56
-rw-r--r--web/play/app.js1
-rw-r--r--web/play/build.mjs22
-rw-r--r--web/play/index.html112
-rw-r--r--web/play/package-lock.json1031
-rw-r--r--web/play/package.json20
-rw-r--r--web/play/src/blackjack.ts152
-rw-r--r--web/play/src/crash.ts365
-rw-r--r--web/play/src/dom.ts104
-rw-r--r--web/play/src/history.ts60
-rw-r--r--web/play/src/main.ts323
-rw-r--r--web/play/src/net.ts120
-rw-r--r--web/play/src/roulette.ts474
-rw-r--r--web/play/src/slots.ts144
-rw-r--r--web/play/src/types.ts27
-rw-r--r--web/play/style.css407
-rw-r--r--web/play/test/smoke.mjs294
-rw-r--r--web/play/tsconfig.json13
22 files changed, 3777 insertions, 3 deletions
diff --git a/web/README.md b/web/README.md
index 81346fd..3e81468 100644
--- a/web/README.md
+++ b/web/README.md
@@ -13,6 +13,7 @@ web/
13 releases.php shared scanner and hash cache 13 releases.php shared scanner and hash cache
14 install.sh stable Linux installer 14 install.sh stable Linux installer
15 install.ps1 stable Windows installer 15 install.ps1 stable Windows installer
16 play/ browser app, served at /play/
16 bin/gui/ apps, served at /bin/gui/... 17 bin/gui/ apps, served at /bin/gui/...
17 bin/client/ terminal clients 18 bin/client/ terminal clients
18 bin/server/ servers 19 bin/server/ servers
@@ -20,6 +21,26 @@ web/
20 21
21Serve `web/` as the document root. Binaries in `bin/` are gitignored. 22Serve `web/` as the document root. Binaries in `bin/` are gitignored.
22 23
24## Browser app
25
26`play/` is a static vanilla TypeScript app. It talks to the WebSocket gateway
27in `webd/`, not to the game server: the browser sends `Command` JSON and renders
28`View` JSON, and the gateway owns the upstream TLS connection and the pinned
29server key. nginx serves `/play/` and proxies `/ws` to `127.0.0.1:8890`.
30
31Build it before publishing:
32
33```sh
34cd web/play
35npm ci
36npm run build # writes app.js
37cd ../..
38```
39
40Commit the generated `app.js`; the site then needs no Node at deploy time.
41`npm run typecheck` checks the sources. The gateway has its own deployment
42notes in `webd/README.md`; it is not part of the player download.
43
23## Naming 44## Naming
24 45
25The page only lists files that match the release pattern; anything else in 46The page only lists files that match the release pattern; anything else in
@@ -132,9 +153,9 @@ Then open <http://127.0.0.1:8899/>.
132`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a 153`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a
133half-written binary, then fetches the live page and every artifact it lists — 154half-written binary, then fetches the live page and every artifact it lists —
134GUI apps, installers, terminal clients and servers — and compares the 155GUI apps, installers, terminal clients and servers — and compares the
135downloaded bytes with the local SHA-256. `--verify-only` skips the 156downloaded bytes with the local SHA-256. It also fetches `/play/`, `app.js` and
136upload and only checks the live site. Old versions are left on the server; 157`style.css`. `--verify-only` skips the upload and only checks the live site.
137prune them by hand. 158Old versions are left on the server; prune them by hand.
138 159
139A local directory works as a target, which is how the verification itself is 160A local directory works as a target, which is how the verification itself is
140tested: 161tested:
diff --git a/web/index.php b/web/index.php
index 3f0ab92..b03eac6 100644
--- a/web/index.php
+++ b/web/index.php
@@ -58,6 +58,7 @@ function human_size(int $bytes): string
58<body> 58<body>
59 59
60<h1>żagań.bet</h1> 60<h1>żagań.bet</h1>
61<p><a href="<?= e($base) ?>/play/"><strong>Play in your browser</strong></a> — no install, just an account. Play-money only.</p>
61<p>Official desktop app, terminal client and server builds.</p> 62<p>Official desktop app, terminal client and server builds.</p>
62<?php if ($newest > 0): ?> 63<?php if ($newest > 0): ?>
63<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p> 64<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p>
diff --git a/web/nginx.conf.example b/web/nginx.conf.example
index 9fc942f..d440ab5 100644
--- a/web/nginx.conf.example
+++ b/web/nginx.conf.example
@@ -37,6 +37,31 @@ server {
37 add_header Cache-Control "public, max-age=31536000, immutable"; 37 add_header Cache-Control "public, max-age=31536000, immutable";
38 } 38 }
39 39
40 # The browser app: static files, no framework runtime.
41 location = /play {
42 return 301 /play/;
43 }
44
45 location /play/ {
46 try_files $uri $uri/ /play/index.html;
47 add_header Cache-Control "no-cache";
48 add_header X-Frame-Options "DENY" always;
49 add_header X-Content-Type-Options "nosniff" always;
50 }
51
52 # WebSocket gateway. Keep the upgrade headers and a long read timeout:
53 # a crash round can idle for a while between pushes.
54 location = /ws {
55 proxy_pass http://127.0.0.1:8890;
56 proxy_http_version 1.1;
57 proxy_set_header Upgrade $http_upgrade;
58 proxy_set_header Connection "upgrade";
59 proxy_set_header Host $host;
60 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
61 proxy_read_timeout 3600s;
62 proxy_send_timeout 3600s;
63 }
64
40 # The page rescans bin/ on every request, so don't cache it. 65 # The page rescans bin/ on every request, so don't cache it.
41 location = /index.php { 66 location = /index.php {
42 include fastcgi_params; 67 include fastcgi_params;
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 @@
1node_modules/
2app.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 @@
1# `web/play` — browser frontend
2
3Vanilla TypeScript + esbuild. The browser sends `Command` JSON and renders
4`View` JSON; it contains no game rules, no payout math and no wire protocol.
5The gateway in `webd/` owns the upstream TLS connection.
6
7## Build
8
9```sh
10npm ci
11npm run build # writes app.js
12npm run typecheck
13npm test # headless DOM smoke test (jsdom)
14```
15
16Commit the generated `app.js` so the site deploys without Node.
17
18## Local development
19
20Serve the repository's `web/` directory (for `/play/`) and put the gateway
21behind the same origin at `/ws`. The simplest local setup is nginx with the
22`web/nginx.conf.example` block pointed at a staging root and
23`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899`. For the app alone:
24
25```sh
26python3 -m http.server 8900 -d web/play
27```
28
29It will load, but the WebSocket will only connect through nginx or another
30proxy that routes `/ws` to `webd`.
31
32## Screens
33
34- Auth: login/register. The password is hashed with Web Crypto (`SHA-256`)
35 before it leaves the page; the gateway and browser never store it.
36- Lobby: four games, per-game bet, history.
37- Slots: five reels, win cells and paylines.
38- Roulette: canvas felt with straight, dozen, column and outside chips;
39 left click adds, right click removes.
40- Blackjack: dealer/player cards, hit/stand/double/forfeit.
41- Crash: curve, live multiplier, cash-out, commit/seed verification.
42- History: settled plays with net result.
43
44## Theme
45
46`style.css` mirrors the palette in `gui/src/theme.rs`: near-black ground,
47cyan focus, green for wins, gold for chips, felt green only for roulette.
48Do not introduce a second palette or hard-code colours in the render modules.
49
50## Security
51
52- The session cookie is HttpOnly and SameSite=Strict; JavaScript never reads
53 it and never stores a credential.
54- Notices are inserted with `textContent`, not HTML.
55- The app is play-money only. Do not add deposits, withdrawals, KYC or
56 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;a<t.length;a+=1)a>0&&(t.length-a)%3===0&&(s+=" "),s+=t[a];return n?`-${s}`:s}function L(e){return e==null||Number.isNaN(e)?"— Kr":`${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;u<s.length;u+=1)s[u]=Number.parseInt(e.slice(u*2,u*2+2),16);let a=await crypto.subtle.digest("SHA-256",s),d=[...new Uint8Array(a)].map(u=>u.toString(16).padStart(2,"0")).join("")===n.toLowerCase(),i=0;for(let u=0;u<8;u+=1)i=i*256+s[u];i/=2**64;let m=Math.max(1,Math.min(1e3,(1-.01)/(1-i))),p=Math.abs(m-t)<1e-6;return[d,p]}function 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<b.rank||T.rank===b.rank&&C<Math.hypot(M-b.x,N-b.y))&&(b=T)}if(b)return{x:b.x-b.radius,y:b.y-b.radius,w:b.radius*2,h:b.radius*2,target:b.target,label:"",kind:"number"};for(let T=m.zones.length-1;T>=0;T-=1){let C=m.zones[T];if(M>=C.x&&M<=C.x+C.w&&N>=C.y&&N<=C.y+C.h)return C}return null};d.addEventListener("click",l=>{if(a)return;let c=p(l.clientX,l.clientY);c&&s({PlaceBetAt:c.target})}),d.addEventListener("contextmenu",l=>{if(l.preventDefault(),a)return;let c=p(l.clientX,l.clientY);c&&s({RemoveBetAt:c.target})}),d.addEventListener("mousemove",l=>{let c=p(l.clientX,l.clientY);d.style.cursor=c&&!a?"pointer":"default"}),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()<oe}function se(e,n,t,s){te=t,ne=e,I!==void 0&&(window.clearInterval(I),I=void 0);let a=it(t);H(e),H(n);let r=o("h2",void 0,"Slots"),d=o("p","note","Match three or more symbols from the left. Five paylines.");e.append(r,d);let i=o("div","reels"),m=[],p=String(t.reels??"");for(let c=0;c<Q;c+=1){let f=o("div","reel");for(let M=0;M<ee;M+=1){let N=c*ee+M,b=a?D[(N*3+Math.floor(Date.now()/70))%D.length]:p.length===Q*ee?p[N]:p.length===Q?p[c]:D[(N*5+c)%D.length],T=o("div","tile",b);T.classList.add(`sym-${b}`),a&&T.classList.add("spinning"),!a&&(t.win_cells??[]).includes(N)&&T.classList.add("win"),m.push(T),f.append(T)}i.append(f)}e.append(i);let u=o("div","win-lines");if(!a&&t.phase==="result"){let c=Number(t.winnings??0);if(c>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<m.length;f+=1){let M=D[(f*3+c)%D.length];m[f].textContent=M,m[f].className=`tile spinning sym-${M}`}Date.now()>=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 @@
1import { build, context } from "esbuild";
2
3const watch = process.argv.includes("--watch");
4
5const options = {
6 entryPoints: ["src/main.ts"],
7 bundle: true,
8 outfile: "app.js",
9 format: "iife",
10 target: ["es2020"],
11 minify: !watch,
12 sourcemap: watch ? "inline" : false,
13 charset: "utf8",
14 logLevel: "info",
15};
16
17if (watch) {
18 const ctx = await context(options);
19 await ctx.watch();
20} else {
21 await build(options);
22}
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 @@
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>Play — żagań.bet</title>
7<meta name="description" content="Play slots, roulette, blackjack and crash with play credits. No real money.">
8<meta name="color-scheme" content="dark">
9<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; base-uri 'none'; form-action 'self'">
10<link rel="stylesheet" href="style.css">
11</head>
12<body>
13<div class="shell">
14 <header class="topbar">
15 <a class="brand" href="/">żagań<span>.bet</span></a>
16 <span class="tagline">Play credits · no real money</span>
17 <span class="spacer"></span>
18 <div id="session-bar" class="session" hidden>
19 <span class="who">Signed in as <strong id="account-name"></strong></span>
20 <span id="account-balance" class="balance">— Kr</span>
21 <button id="logout" class="btn ghost small" type="button">Log out</button>
22 </div>
23 </header>
24
25 <main>
26 <section id="auth-screen" hidden>
27 <div class="auth-wrap">
28 <div class="auth-card">
29 <h1>Play żagań.bet</h1>
30 <p class="muted" style="margin: 6px 0 20px">Play-money casino. Your balance is play credit; nothing here is real money.</p>
31 <div class="auth-tabs" role="tablist">
32 <button id="tab-login" class="auth-tab active" type="button" role="tab">Log in</button>
33 <button id="tab-register" class="auth-tab" type="button" role="tab">Register</button>
34 </div>
35 <form id="auth-form">
36 <label class="field">
37 <span class="label">Username</span>
38 <input id="auth-user" class="input" type="text" autocomplete="username" maxlength="32" required>
39 </label>
40 <label class="field">
41 <span class="label">Password</span>
42 <input id="auth-pass" class="input" type="password" autocomplete="current-password" required>
43 </label>
44 <p id="auth-error" class="error-text" style="margin: 0; min-height: 18px"></p>
45 <button id="auth-submit" class="btn primary wide big" type="submit">Log in</button>
46 </form>
47 <p class="auth-foot">Registration gives you 1 000 Kr of play credit. No deposits, no withdrawals, no personal data beyond a username.</p>
48 </div>
49 </div>
50 </section>
51
52 <section id="lobby-screen" hidden>
53 <div class="lobby-head">
54 <div>
55 <h1>Lobby</h1>
56 <p class="muted" style="margin: 4px 0 0">Choose a game. Bets are play credits.</p>
57 </div>
58 <span class="spacer"></span>
59 <button id="show-history" class="btn ghost" type="button">Play history</button>
60 <button id="refresh-lobby" class="btn ghost" type="button">Refresh</button>
61 </div>
62 <div id="game-grid" class="game-grid"></div>
63 </section>
64
65 <section id="game-screen" hidden>
66 <div class="game-head">
67 <button id="leave-game" class="btn ghost" type="button">← Lobby</button>
68 <h1 id="game-title">Game</h1>
69 <span class="spacer"></span>
70 <span id="game-badge" class="label"></span>
71 </div>
72 <div class="game-body">
73 <div id="game-board" class="board"></div>
74 <aside id="game-side" class="sidebar"></aside>
75 </div>
76 </section>
77
78 <section id="history-screen" hidden>
79 <div class="lobby-head">
80 <div>
81 <h1>Play history</h1>
82 <p class="muted" style="margin: 4px 0 0">The last plays on your account.</p>
83 </div>
84 <span class="spacer"></span>
85 <button id="history-back" class="btn ghost" type="button">← Lobby</button>
86 </div>
87 <div id="history-body"></div>
88 </section>
89 </main>
90</div>
91
92<div id="toasts" aria-live="polite"></div>
93
94<div id="replaced-overlay" class="overlay" hidden>
95 <div class="modal">
96 <h2>Opened in another tab</h2>
97 <p>This game was opened somewhere else. Only one tab can play a session at a time.</p>
98 <button id="reload-replaced" class="btn primary" type="button">Reload this tab</button>
99 </div>
100</div>
101
102<div id="reconnect-overlay" class="overlay" hidden>
103 <div class="modal">
104 <h2>Connection lost</h2>
105 <p id="reconnect-message">Trying to reach the game server…</p>
106 <button id="reload-reconnect" class="btn ghost" type="button">Reload</button>
107 </div>
108</div>
109
110<script src="app.js" defer></script>
111</body>
112</html>
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 @@
1{
2 "name": "zagan-bet-play",
3 "version": "5.0.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "zagan-bet-play",
9 "version": "5.0.0",
10 "devDependencies": {
11 "esbuild": "^0.28.0",
12 "jsdom": "^30.1.3",
13 "typescript": "^5.9.0"
14 }
15 },
16 "node_modules/@asamuzakjp/css-color": {
17 "version": "7.1.9",
18 "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz",
19 "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==",
20 "dev": true,
21 "license": "MIT",
22 "dependencies": {
23 "@csstools/css-calc": "^3.4.3",
24 "@csstools/css-color-parser": "^4.2.6",
25 "@csstools/css-parser-algorithms": "^4.0.2",
26 "@csstools/css-tokenizer": "^4.0.2",
27 "lru-cache": "^11.5.3"
28 },
29 "engines": {
30 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
31 }
32 },
33 "node_modules/@asamuzakjp/dom-selector": {
34 "version": "10.0.5",
35 "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
36 "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
37 "dev": true,
38 "license": "MIT",
39 "dependencies": {
40 "bidi-js": "^1.1.0",
41 "css-tree": "^3.2.1",
42 "is-potential-custom-element-name": "^1.0.1",
43 "lru-cache": "^11.5.3"
44 },
45 "engines": {
46 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
47 }
48 },
49 "node_modules/@bramus/specificity": {
50 "version": "2.4.3",
51 "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
52 "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
53 "dev": true,
54 "license": "MIT",
55 "dependencies": {
56 "css-tree": "^3.2.1"
57 },
58 "bin": {
59 "specificity": "bin/cli.js"
60 }
61 },
62 "node_modules/@csstools/color-helpers": {
63 "version": "6.1.2",
64 "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
65 "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
66 "dev": true,
67 "funding": [
68 {
69 "type": "github",
70 "url": "https://github.com/sponsors/csstools"
71 },
72 {
73 "type": "opencollective",
74 "url": "https://opencollective.com/csstools"
75 }
76 ],
77 "license": "MIT-0",
78 "engines": {
79 "node": ">=20.19.0"
80 }
81 },
82 "node_modules/@csstools/css-calc": {
83 "version": "3.4.3",
84 "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
85 "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
86 "dev": true,
87 "funding": [
88 {
89 "type": "github",
90 "url": "https://github.com/sponsors/csstools"
91 },
92 {
93 "type": "opencollective",
94 "url": "https://opencollective.com/csstools"
95 }
96 ],
97 "license": "MIT",
98 "engines": {
99 "node": ">=20.19.0"
100 },
101 "peerDependencies": {
102 "@csstools/css-parser-algorithms": "^4.0.2",
103 "@csstools/css-tokenizer": "^4.0.2"
104 }
105 },
106 "node_modules/@csstools/css-color-parser": {
107 "version": "4.2.7",
108 "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
109 "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
110 "dev": true,
111 "funding": [
112 {
113 "type": "github",
114 "url": "https://github.com/sponsors/csstools"
115 },
116 {
117 "type": "opencollective",
118 "url": "https://opencollective.com/csstools"
119 }
120 ],
121 "license": "MIT",
122 "dependencies": {
123 "@csstools/color-helpers": "^6.1.2",
124 "@csstools/css-calc": "^3.4.3"
125 },
126 "engines": {
127 "node": ">=20.19.0"
128 },
129 "peerDependencies": {
130 "@csstools/css-parser-algorithms": "^4.0.2",
131 "@csstools/css-tokenizer": "^4.0.2"
132 }
133 },
134 "node_modules/@csstools/css-parser-algorithms": {
135 "version": "4.0.2",
136 "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
137 "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
138 "dev": true,
139 "funding": [
140 {
141 "type": "github",
142 "url": "https://github.com/sponsors/csstools"
143 },
144 {
145 "type": "opencollective",
146 "url": "https://opencollective.com/csstools"
147 }
148 ],
149 "license": "MIT",
150 "engines": {
151 "node": ">=20.19.0"
152 },
153 "peerDependencies": {
154 "@csstools/css-tokenizer": "^4.0.2"
155 }
156 },
157 "node_modules/@csstools/css-syntax-patches-for-csstree": {
158 "version": "1.1.16",
159 "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
160 "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
161 "dev": true,
162 "funding": [
163 {
164 "type": "github",
165 "url": "https://github.com/sponsors/csstools"
166 },
167 {
168 "type": "opencollective",
169 "url": "https://opencollective.com/csstools"
170 }
171 ],
172 "license": "MIT-0",
173 "peerDependencies": {
174 "css-tree": "^3.2.1"
175 },
176 "peerDependenciesMeta": {
177 "css-tree": {
178 "optional": true
179 }
180 }
181 },
182 "node_modules/@csstools/css-tokenizer": {
183 "version": "4.0.2",
184 "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
185 "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
186 "dev": true,
187 "funding": [
188 {
189 "type": "github",
190 "url": "https://github.com/sponsors/csstools"
191 },
192 {
193 "type": "opencollective",
194 "url": "https://opencollective.com/csstools"
195 }
196 ],
197 "license": "MIT",
198 "engines": {
199 "node": ">=20.19.0"
200 }
201 },
202 "node_modules/@esbuild/aix-ppc64": {
203 "version": "0.28.2",
204 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
205 "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
206 "cpu": [
207 "ppc64"
208 ],
209 "dev": true,
210 "license": "MIT",
211 "optional": true,
212 "os": [
213 "aix"
214 ],
215 "engines": {
216 "node": ">=18"
217 }
218 },
219 "node_modules/@esbuild/android-arm": {
220 "version": "0.28.2",
221 "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
222 "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
223 "cpu": [
224 "arm"
225 ],
226 "dev": true,
227 "license": "MIT",
228 "optional": true,
229 "os": [
230 "android"
231 ],
232 "engines": {
233 "node": ">=18"
234 }
235 },
236 "node_modules/@esbuild/android-arm64": {
237 "version": "0.28.2",
238 "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
239 "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
240 "cpu": [
241 "arm64"
242 ],
243 "dev": true,
244 "license": "MIT",
245 "optional": true,
246 "os": [
247 "android"
248 ],
249 "engines": {
250 "node": ">=18"
251 }
252 },
253 "node_modules/@esbuild/android-x64": {
254 "version": "0.28.2",
255 "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
256 "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
257 "cpu": [
258 "x64"
259 ],
260 "dev": true,
261 "license": "MIT",
262 "optional": true,
263 "os": [
264 "android"
265 ],
266 "engines": {
267 "node": ">=18"
268 }
269 },
270 "node_modules/@esbuild/darwin-arm64": {
271 "version": "0.28.2",
272 "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
273 "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
274 "cpu": [
275 "arm64"
276 ],
277 "dev": true,
278 "license": "MIT",
279 "optional": true,
280 "os": [
281 "darwin"
282 ],
283 "engines": {
284 "node": ">=18"
285 }
286 },
287 "node_modules/@esbuild/darwin-x64": {
288 "version": "0.28.2",
289 "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
290 "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
291 "cpu": [
292 "x64"
293 ],
294 "dev": true,
295 "license": "MIT",
296 "optional": true,
297 "os": [
298 "darwin"
299 ],
300 "engines": {
301 "node": ">=18"
302 }
303 },
304 "node_modules/@esbuild/freebsd-arm64": {
305 "version": "0.28.2",
306 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
307 "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
308 "cpu": [
309 "arm64"
310 ],
311 "dev": true,
312 "license": "MIT",
313 "optional": true,
314 "os": [
315 "freebsd"
316 ],
317 "engines": {
318 "node": ">=18"
319 }
320 },
321 "node_modules/@esbuild/freebsd-x64": {
322 "version": "0.28.2",
323 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
324 "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
325 "cpu": [
326 "x64"
327 ],
328 "dev": true,
329 "license": "MIT",
330 "optional": true,
331 "os": [
332 "freebsd"
333 ],
334 "engines": {
335 "node": ">=18"
336 }
337 },
338 "node_modules/@esbuild/linux-arm": {
339 "version": "0.28.2",
340 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
341 "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
342 "cpu": [
343 "arm"
344 ],
345 "dev": true,
346 "license": "MIT",
347 "optional": true,
348 "os": [
349 "linux"
350 ],
351 "engines": {
352 "node": ">=18"
353 }
354 },
355 "node_modules/@esbuild/linux-arm64": {
356 "version": "0.28.2",
357 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
358 "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
359 "cpu": [
360 "arm64"
361 ],
362 "dev": true,
363 "license": "MIT",
364 "optional": true,
365 "os": [
366 "linux"
367 ],
368 "engines": {
369 "node": ">=18"
370 }
371 },
372 "node_modules/@esbuild/linux-ia32": {
373 "version": "0.28.2",
374 "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
375 "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
376 "cpu": [
377 "ia32"
378 ],
379 "dev": true,
380 "license": "MIT",
381 "optional": true,
382 "os": [
383 "linux"
384 ],
385 "engines": {
386 "node": ">=18"
387 }
388 },
389 "node_modules/@esbuild/linux-loong64": {
390 "version": "0.28.2",
391 "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
392 "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
393 "cpu": [
394 "loong64"
395 ],
396 "dev": true,
397 "license": "MIT",
398 "optional": true,
399 "os": [
400 "linux"
401 ],
402 "engines": {
403 "node": ">=18"
404 }
405 },
406 "node_modules/@esbuild/linux-mips64el": {
407 "version": "0.28.2",
408 "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
409 "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
410 "cpu": [
411 "mips64el"
412 ],
413 "dev": true,
414 "license": "MIT",
415 "optional": true,
416 "os": [
417 "linux"
418 ],
419 "engines": {
420 "node": ">=18"
421 }
422 },
423 "node_modules/@esbuild/linux-ppc64": {
424 "version": "0.28.2",
425 "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
426 "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
427 "cpu": [
428 "ppc64"
429 ],
430 "dev": true,
431 "license": "MIT",
432 "optional": true,
433 "os": [
434 "linux"
435 ],
436 "engines": {
437 "node": ">=18"
438 }
439 },
440 "node_modules/@esbuild/linux-riscv64": {
441 "version": "0.28.2",
442 "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
443 "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
444 "cpu": [
445 "riscv64"
446 ],
447 "dev": true,
448 "license": "MIT",
449 "optional": true,
450 "os": [
451 "linux"
452 ],
453 "engines": {
454 "node": ">=18"
455 }
456 },
457 "node_modules/@esbuild/linux-s390x": {
458 "version": "0.28.2",
459 "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
460 "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
461 "cpu": [
462 "s390x"
463 ],
464 "dev": true,
465 "license": "MIT",
466 "optional": true,
467 "os": [
468 "linux"
469 ],
470 "engines": {
471 "node": ">=18"
472 }
473 },
474 "node_modules/@esbuild/linux-x64": {
475 "version": "0.28.2",
476 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
477 "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
478 "cpu": [
479 "x64"
480 ],
481 "dev": true,
482 "license": "MIT",
483 "optional": true,
484 "os": [
485 "linux"
486 ],
487 "engines": {
488 "node": ">=18"
489 }
490 },
491 "node_modules/@esbuild/netbsd-arm64": {
492 "version": "0.28.2",
493 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
494 "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
495 "cpu": [
496 "arm64"
497 ],
498 "dev": true,
499 "license": "MIT",
500 "optional": true,
501 "os": [
502 "netbsd"
503 ],
504 "engines": {
505 "node": ">=18"
506 }
507 },
508 "node_modules/@esbuild/netbsd-x64": {
509 "version": "0.28.2",
510 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
511 "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
512 "cpu": [
513 "x64"
514 ],
515 "dev": true,
516 "license": "MIT",
517 "optional": true,
518 "os": [
519 "netbsd"
520 ],
521 "engines": {
522 "node": ">=18"
523 }
524 },
525 "node_modules/@esbuild/openbsd-arm64": {
526 "version": "0.28.2",
527 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
528 "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
529 "cpu": [
530 "arm64"
531 ],
532 "dev": true,
533 "license": "MIT",
534 "optional": true,
535 "os": [
536 "openbsd"
537 ],
538 "engines": {
539 "node": ">=18"
540 }
541 },
542 "node_modules/@esbuild/openbsd-x64": {
543 "version": "0.28.2",
544 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
545 "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
546 "cpu": [
547 "x64"
548 ],
549 "dev": true,
550 "license": "MIT",
551 "optional": true,
552 "os": [
553 "openbsd"
554 ],
555 "engines": {
556 "node": ">=18"
557 }
558 },
559 "node_modules/@esbuild/openharmony-arm64": {
560 "version": "0.28.2",
561 "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
562 "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
563 "cpu": [
564 "arm64"
565 ],
566 "dev": true,
567 "license": "MIT",
568 "optional": true,
569 "os": [
570 "openharmony"
571 ],
572 "engines": {
573 "node": ">=18"
574 }
575 },
576 "node_modules/@esbuild/sunos-x64": {
577 "version": "0.28.2",
578 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
579 "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
580 "cpu": [
581 "x64"
582 ],
583 "dev": true,
584 "license": "MIT",
585 "optional": true,
586 "os": [
587 "sunos"
588 ],
589 "engines": {
590 "node": ">=18"
591 }
592 },
593 "node_modules/@esbuild/win32-arm64": {
594 "version": "0.28.2",
595 "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
596 "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
597 "cpu": [
598 "arm64"
599 ],
600 "dev": true,
601 "license": "MIT",
602 "optional": true,
603 "os": [
604 "win32"
605 ],
606 "engines": {
607 "node": ">=18"
608 }
609 },
610 "node_modules/@esbuild/win32-ia32": {
611 "version": "0.28.2",
612 "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
613 "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
614 "cpu": [
615 "ia32"
616 ],
617 "dev": true,
618 "license": "MIT",
619 "optional": true,
620 "os": [
621 "win32"
622 ],
623 "engines": {
624 "node": ">=18"
625 }
626 },
627 "node_modules/@esbuild/win32-x64": {
628 "version": "0.28.2",
629 "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
630 "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
631 "cpu": [
632 "x64"
633 ],
634 "dev": true,
635 "license": "MIT",
636 "optional": true,
637 "os": [
638 "win32"
639 ],
640 "engines": {
641 "node": ">=18"
642 }
643 },
644 "node_modules/@exodus/bytes": {
645 "version": "1.16.0",
646 "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
647 "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
648 "dev": true,
649 "license": "MIT",
650 "engines": {
651 "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
652 },
653 "peerDependencies": {
654 "@noble/hashes": "^1.8.0 || ^2.0.0"
655 },
656 "peerDependenciesMeta": {
657 "@noble/hashes": {
658 "optional": true
659 }
660 }
661 },
662 "node_modules/bidi-js": {
663 "version": "1.1.0",
664 "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
665 "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
666 "dev": true,
667 "license": "MIT",
668 "dependencies": {
669 "require-from-string": "^2.0.2"
670 }
671 },
672 "node_modules/css-tree": {
673 "version": "3.2.1",
674 "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
675 "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
676 "dev": true,
677 "license": "MIT",
678 "dependencies": {
679 "mdn-data": "2.27.1",
680 "source-map-js": "^1.2.1"
681 },
682 "engines": {
683 "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
684 }
685 },
686 "node_modules/data-urls": {
687 "version": "8.0.0",
688 "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
689 "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
690 "dev": true,
691 "license": "MIT",
692 "dependencies": {
693 "whatwg-mimetype": "^5.0.0",
694 "whatwg-url": "^17.1.2"
695 },
696 "engines": {
697 "node": "^22.14.0 || >=24.0.0"
698 }
699 },
700 "node_modules/decimal.js": {
701 "version": "10.6.0",
702 "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
703 "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
704 "dev": true,
705 "license": "MIT"
706 },
707 "node_modules/entities": {
708 "version": "8.1.0",
709 "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
710 "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
711 "dev": true,
712 "license": "BSD-2-Clause",
713 "engines": {
714 "node": ">=20.19.0"
715 },
716 "funding": {
717 "url": "https://github.com/fb55/entities?sponsor=1"
718 }
719 },
720 "node_modules/esbuild": {
721 "version": "0.28.2",
722 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
723 "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
724 "dev": true,
725 "hasInstallScript": true,
726 "license": "MIT",
727 "bin": {
728 "esbuild": "bin/esbuild"
729 },
730 "engines": {
731 "node": ">=18"
732 },
733 "optionalDependencies": {
734 "@esbuild/aix-ppc64": "0.28.2",
735 "@esbuild/android-arm": "0.28.2",
736 "@esbuild/android-arm64": "0.28.2",
737 "@esbuild/android-x64": "0.28.2",
738 "@esbuild/darwin-arm64": "0.28.2",
739 "@esbuild/darwin-x64": "0.28.2",
740 "@esbuild/freebsd-arm64": "0.28.2",
741 "@esbuild/freebsd-x64": "0.28.2",
742 "@esbuild/linux-arm": "0.28.2",
743 "@esbuild/linux-arm64": "0.28.2",
744 "@esbuild/linux-ia32": "0.28.2",
745 "@esbuild/linux-loong64": "0.28.2",
746 "@esbuild/linux-mips64el": "0.28.2",
747 "@esbuild/linux-ppc64": "0.28.2",
748 "@esbuild/linux-riscv64": "0.28.2",
749 "@esbuild/linux-s390x": "0.28.2",
750 "@esbuild/linux-x64": "0.28.2",
751 "@esbuild/netbsd-arm64": "0.28.2",
752 "@esbuild/netbsd-x64": "0.28.2",
753 "@esbuild/openbsd-arm64": "0.28.2",
754 "@esbuild/openbsd-x64": "0.28.2",
755 "@esbuild/openharmony-arm64": "0.28.2",
756 "@esbuild/sunos-x64": "0.28.2",
757 "@esbuild/win32-arm64": "0.28.2",
758 "@esbuild/win32-ia32": "0.28.2",
759 "@esbuild/win32-x64": "0.28.2"
760 }
761 },
762 "node_modules/html-encoding-sniffer": {
763 "version": "7.0.0",
764 "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
765 "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
766 "dev": true,
767 "license": "MIT",
768 "dependencies": {
769 "@exodus/bytes": "^1.15.1"
770 },
771 "engines": {
772 "node": "^22.13.0 || >=24.0.0"
773 }
774 },
775 "node_modules/is-potential-custom-element-name": {
776 "version": "1.0.1",
777 "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
778 "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
779 "dev": true,
780 "license": "MIT"
781 },
782 "node_modules/jsdom": {
783 "version": "30.1.3",
784 "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
785 "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
786 "dev": true,
787 "license": "MIT",
788 "dependencies": {
789 "@asamuzakjp/css-color": "^7.1.3",
790 "@asamuzakjp/dom-selector": "^10.0.3",
791 "@bramus/specificity": "^2.4.2",
792 "@csstools/css-syntax-patches-for-csstree": "^1.1.15",
793 "@exodus/bytes": "^1.16.0",
794 "css-tree": "^3.2.1",
795 "data-urls": "^8.0.0",
796 "decimal.js": "^10.6.0",
797 "html-encoding-sniffer": "^7.0.0",
798 "is-potential-custom-element-name": "^1.0.1",
799 "lru-cache": "^11.5.3",
800 "parse5": "^8.0.1",
801 "saxes": "^6.0.0",
802 "tough-cookie": "^6.0.2",
803 "undici": "^8.12.1",
804 "w3c-xmlserializer": "^6.0.0",
805 "webidl-conversions": "^8.0.1",
806 "whatwg-mimetype": "^5.0.0",
807 "whatwg-url": "^17.2.0",
808 "xml-name-validator": "^5.0.0"
809 },
810 "engines": {
811 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
812 },
813 "peerDependencies": {
814 "canvas": "^3.2.3"
815 },
816 "peerDependenciesMeta": {
817 "canvas": {
818 "optional": true
819 }
820 }
821 },
822 "node_modules/lru-cache": {
823 "version": "11.5.3",
824 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
825 "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
826 "dev": true,
827 "license": "BlueOak-1.0.0",
828 "engines": {
829 "node": "20 || >=22"
830 }
831 },
832 "node_modules/mdn-data": {
833 "version": "2.27.1",
834 "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
835 "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
836 "dev": true,
837 "license": "CC0-1.0"
838 },
839 "node_modules/parse5": {
840 "version": "8.0.1",
841 "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
842 "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
843 "dev": true,
844 "license": "MIT",
845 "dependencies": {
846 "entities": "^8.0.0"
847 },
848 "funding": {
849 "url": "https://github.com/inikulin/parse5?sponsor=1"
850 }
851 },
852 "node_modules/punycode": {
853 "version": "2.3.1",
854 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
855 "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
856 "dev": true,
857 "license": "MIT",
858 "engines": {
859 "node": ">=6"
860 }
861 },
862 "node_modules/require-from-string": {
863 "version": "2.0.2",
864 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
865 "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
866 "dev": true,
867 "license": "MIT",
868 "engines": {
869 "node": ">=0.10.0"
870 }
871 },
872 "node_modules/saxes": {
873 "version": "6.0.0",
874 "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
875 "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
876 "dev": true,
877 "license": "ISC",
878 "dependencies": {
879 "xmlchars": "^2.2.0"
880 },
881 "engines": {
882 "node": ">=v12.22.7"
883 }
884 },
885 "node_modules/source-map-js": {
886 "version": "1.2.2",
887 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
888 "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
889 "dev": true,
890 "license": "BSD-3-Clause",
891 "engines": {
892 "node": ">=0.10.0"
893 }
894 },
895 "node_modules/tldts": {
896 "version": "7.4.19",
897 "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
898 "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
899 "dev": true,
900 "license": "MIT",
901 "dependencies": {
902 "tldts-core": "^7.4.19"
903 },
904 "bin": {
905 "tldts": "bin/cli.js"
906 }
907 },
908 "node_modules/tldts-core": {
909 "version": "7.4.20",
910 "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
911 "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
912 "dev": true,
913 "license": "MIT"
914 },
915 "node_modules/tough-cookie": {
916 "version": "6.0.2",
917 "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
918 "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
919 "dev": true,
920 "license": "BSD-3-Clause",
921 "dependencies": {
922 "tldts": "^7.0.5"
923 },
924 "engines": {
925 "node": ">=16"
926 }
927 },
928 "node_modules/tr46": {
929 "version": "7.0.0",
930 "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
931 "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
932 "dev": true,
933 "license": "MIT",
934 "dependencies": {
935 "punycode": "^2.3.1"
936 },
937 "engines": {
938 "node": "^22.14.0 || >=24.0.0"
939 }
940 },
941 "node_modules/typescript": {
942 "version": "5.9.3",
943 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
944 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
945 "dev": true,
946 "license": "Apache-2.0",
947 "bin": {
948 "tsc": "bin/tsc",
949 "tsserver": "bin/tsserver"
950 },
951 "engines": {
952 "node": ">=14.17"
953 }
954 },
955 "node_modules/undici": {
956 "version": "8.12.3",
957 "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
958 "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
959 "dev": true,
960 "license": "MIT",
961 "engines": {
962 "node": ">=22.19.0"
963 }
964 },
965 "node_modules/w3c-xmlserializer": {
966 "version": "6.0.0",
967 "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
968 "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
969 "dev": true,
970 "license": "MIT",
971 "dependencies": {
972 "xml-name-validator": "^5.0.0"
973 },
974 "engines": {
975 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
976 }
977 },
978 "node_modules/webidl-conversions": {
979 "version": "8.0.1",
980 "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
981 "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
982 "dev": true,
983 "license": "BSD-2-Clause",
984 "engines": {
985 "node": ">=20"
986 }
987 },
988 "node_modules/whatwg-mimetype": {
989 "version": "5.0.0",
990 "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
991 "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
992 "dev": true,
993 "license": "MIT",
994 "engines": {
995 "node": ">=20"
996 }
997 },
998 "node_modules/whatwg-url": {
999 "version": "17.3.0",
1000 "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
1001 "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
1002 "dev": true,
1003 "license": "MIT",
1004 "dependencies": {
1005 "@exodus/bytes": "^1.16.0",
1006 "tr46": "^7.0.0",
1007 "webidl-conversions": "^8.0.1"
1008 },
1009 "engines": {
1010 "node": "^22.14.0 || >=24.0.0"
1011 }
1012 },
1013 "node_modules/xml-name-validator": {
1014 "version": "5.0.0",
1015 "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
1016 "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
1017 "dev": true,
1018 "license": "Apache-2.0",
1019 "engines": {
1020 "node": ">=18"
1021 }
1022 },
1023 "node_modules/xmlchars": {
1024 "version": "2.2.0",
1025 "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
1026 "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
1027 "dev": true,
1028 "license": "MIT"
1029 }
1030 }
1031}
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 @@
1{
2 "name": "zagan-bet-play",
3 "private": true,
4 "version": "5.0.0",
5 "type": "module",
6 "scripts": {
7 "build": "node build.mjs",
8 "watch": "node build.mjs --watch",
9 "typecheck": "tsc --noEmit",
10 "test": "node test/smoke.mjs"
11 },
12 "devDependencies": {
13 "esbuild": "^0.28.0",
14 "jsdom": "^30.1.3",
15 "typescript": "^5.9.0"
16 },
17 "engines": {
18 "node": ">=20"
19 }
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 @@
1import type { View } from "./types";
2import { cardFace, clear, money, node } from "./dom";
3
4function cardElement(card: string): HTMLElement {
5 if (card === "?") {
6 const back = node("div", "card back");
7 back.append(node("span", "rank", "?"), node("span", "suit", ""));
8 return back;
9 }
10 const face = cardFace(card);
11 const element = node("div", "card");
12 if (face.red) element.classList.add("red");
13 element.append(node("span", "rank", face.rank), node("span", "suit", face.suit));
14 return element;
15}
16
17function handBlock(label: string, cards: string[], total: number): HTMLElement {
18 const block = node("div", "hand");
19 block.append(node("div", "label", label));
20 const row = node("div", "cards");
21 for (const card of cards) row.append(cardElement(card));
22 const sum = node("div", "total");
23 sum.append(document.createTextNode("Total "), node("strong", undefined, String(total)));
24 block.append(row, sum);
25 return block;
26}
27
28function outcome(view: View, hand: any): string {
29 const result = String(hand?.result ?? "");
30 const winnings = Number(hand?.winnings ?? 0);
31 switch (result) {
32 case "blackjack":
33 return `Blackjack — won ${money(winnings)}`;
34 case "win":
35 return `You win — ${money(winnings)}`;
36 case "push":
37 return `Push — ${money(winnings)} returned`;
38 case "bust":
39 return "Bust — the bet is lost";
40 case "lose":
41 return "Dealer wins — the bet is lost";
42 case "forfeit":
43 return "Hand forfeited — the bet is lost";
44 case "playing":
45 return view.status === "player" ? "Your move." : "Dealing…";
46 default:
47 return "Ready to deal.";
48 }
49}
50
51export function renderBlackjack(
52 board: HTMLElement,
53 side: HTMLElement,
54 view: View,
55 send: (command: unknown) => void,
56): void {
57 clear(board);
58 clear(side);
59
60 const hand = view.hand;
61 const playerPhase = hand?.phase === "player" || view.status === "player";
62 const done = hand?.phase === "done" || view.status === "done";
63 const canDeal = !hand || done;
64
65 board.append(node("h2", undefined, "Blackjack"));
66 board.append(
67 node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."),
68 );
69
70 if (hand) {
71 board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0)));
72 board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0)));
73 } else {
74 const empty = node("div", "outcome");
75 empty.textContent = "Press DEAL to start a hand.";
76 board.append(empty);
77 }
78
79 if (done || (hand && !playerPhase)) {
80 const result = node("div", "outcome");
81 result.textContent = outcome(view, hand);
82 if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") {
83 result.classList.add("win-text");
84 }
85 board.append(result);
86 }
87
88 if (view.error) board.append(node("p", "error-text", String(view.error)));
89
90 // Sidebar
91 side.append(node("div", "label", "Blackjack"));
92 const bet = Number(hand?.bet ?? view.bet ?? 0);
93 const balance = view.balance ?? view.hand?.balance;
94 side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet."));
95
96 if (canDeal) {
97 const betRow = node("div", "row");
98 const minus = node("button", "btn small", "−10");
99 const amount = node("strong", "mono", money(bet));
100 const plus = node("button", "btn small", "+10");
101 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
102 plus.addEventListener("click", () => send({ AdjustBet: 10 }));
103 betRow.append(minus, amount, plus);
104 side.append(node("div", "label", "Bet"), betRow);
105 } else {
106 const stat = node("div", "stat");
107 stat.append(node("span", "muted", "Bet"), node("strong", undefined, money(bet)));
108 side.append(stat);
109 }
110
111 if (balance !== undefined) {
112 const stat = node("div", "stat");
113 stat.append(node("span", "muted", "Balance"), node("strong", undefined, money(Number(balance))));
114 side.append(stat);
115 }
116
117 const actions = node("div", "actions");
118 if (playerPhase) {
119 const actionsRow = node("div", "row wrap");
120 for (const action of hand?.actions ?? []) {
121 const label = action === "hit" ? "HIT" : action === "stand" ? "STAND" : "DOUBLE";
122 const button =
123 action === "stand"
124 ? node("button", "btn primary", label)
125 : node("button", "btn", label);
126 button.addEventListener("click", () => {
127 if (action === "hit") send("Hit");
128 else if (action === "stand") send("Stand");
129 else send("Double");
130 });
131 actionsRow.append(button);
132 }
133 actions.append(actionsRow);
134 const forfeit = node("button", "btn danger wide", "Forfeit hand");
135 forfeit.addEventListener("click", () => {
136 if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit");
137 });
138 actions.append(forfeit);
139 } else if (canDeal) {
140 const deal = node("button", "btn primary wide big", "DEAL");
141 deal.addEventListener("click", () => send("Deal"));
142 actions.append(deal);
143 }
144
145 const leave = node("button", "btn ghost wide", playerPhase ? "Leave (forfeits)" : "Leave table");
146 leave.addEventListener("click", () => {
147 if (playerPhase && !window.confirm("Leaving forfeits this hand. Continue?")) return;
148 send("Leave");
149 });
150 actions.append(leave);
151 side.append(actions);
152}
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 @@
1import type { View } from "./types";
2import { clear, money, node, shortHash } from "./dom";
3
4const GROWTH = Math.LN2 / 5;
5
6type Clock = { now: () => number };
7
8type Parts = {
9 board: HTMLElement;
10 side: HTMLElement;
11 number: HTMLElement;
12 meta: HTMLElement;
13 verify: HTMLElement;
14 canvas: HTMLCanvasElement;
15 bet: HTMLElement;
16 potential: HTMLElement;
17 balance: HTMLElement;
18 status: HTMLElement;
19 lastCrash: HTMLElement;
20 error: HTMLElement;
21 primary: HTMLButtonElement;
22 leave: HTMLButtonElement;
23 minus: HTMLButtonElement;
24 plus: HTMLButtonElement;
25};
26
27let parts: Parts | null = null;
28let current: View | null = null;
29let clock: Clock | null = null;
30let sendCommand: ((command: unknown) => void) | null = null;
31let frameHandle: number | undefined;
32let verifyToken = 0;
33let lastStatus = "";
34
35function build(
36 board: HTMLElement,
37 side: HTMLElement,
38 send: (command: unknown) => void,
39 now: Clock,
40): Parts {
41 clear(board);
42 clear(side);
43 sendCommand = send;
44 clock = now;
45
46 board.append(node("h2", undefined, "Crash"));
47 board.append(
48 node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."),
49 );
50
51 const wrap = node("div", "crash-wrap");
52 const number = node("div", "crash-number", "1.00×");
53 const meta = node("div", "crash-meta");
54 const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement;
55 const verify = node("div", "verify", "Waiting for a round commitment.");
56 wrap.append(number, meta, canvas, verify);
57 board.append(wrap);
58
59 side.append(node("div", "label", "Crash"));
60 side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends."));
61
62 const betRow = node("div", "row");
63 const minus = node("button", "btn small", "−10") as HTMLButtonElement;
64 const bet = node("strong", "mono", "—");
65 const plus = node("button", "btn small", "+10") as HTMLButtonElement;
66 minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 }));
67 plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 }));
68 betRow.append(minus, bet, plus);
69 side.append(node("div", "label", "Bet"), betRow);
70
71 const potential = node("strong", "mono", "—");
72 const balance = node("strong", "mono", "—");
73 for (const [label, value] of [
74 ["Potential", potential],
75 ["Balance", balance],
76 ] as const) {
77 const row = node("div", "stat");
78 row.append(node("span", "muted", label), value);
79 side.append(row);
80 }
81
82 const status = node("p", "note", "Ready.");
83 const lastCrash = node("p", "note", "");
84 const error = node("p", "error-text");
85 side.append(status, lastCrash, error);
86
87 const actions = node("div", "actions");
88 const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement;
89 const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement;
90 primary.addEventListener("click", () => {
91 if (current?.status === "running") sendCommand?.("CashOut");
92 else sendCommand?.("Start");
93 });
94 leave.addEventListener("click", () => {
95 if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return;
96 sendCommand?.("Leave");
97 });
98 actions.append(primary, leave);
99 side.append(actions);
100
101 return {
102 board,
103 side,
104 number,
105 meta,
106 verify,
107 canvas,
108 bet,
109 potential,
110 balance,
111 status,
112 lastCrash,
113 error,
114 primary,
115 leave,
116 minus,
117 plus,
118 };
119}
120
121export function renderCrash(
122 board: HTMLElement,
123 side: HTMLElement,
124 view: View,
125 send: (command: unknown) => void,
126 now: Clock,
127): void {
128 if (!parts || parts.board !== board) {
129 parts = build(board, side, send, now);
130 lastStatus = "";
131 }
132 const p = parts;
133 sendCommand = send;
134 clock = now;
135 current = view;
136
137 if (view.status !== lastStatus) {
138 if (view.status === "starting" || view.status === "running") {
139 if (lastStatus !== "running") {
140 p.verify.className = "verify";
141 p.verify.textContent = "Round committed; seed hidden until the round ends.";
142 }
143 }
144 lastStatus = String(view.status);
145 }
146
147 const running = view.status === "running";
148 const starting = view.status === "starting";
149 const live = running || starting;
150
151 p.bet.textContent = money(Number(view.bet ?? 0));
152 p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0));
153 p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance));
154 p.error.textContent = view.error ? String(view.error) : "";
155
156 p.minus.disabled = live;
157 p.plus.disabled = live;
158 p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START";
159 p.primary.disabled = starting;
160 p.primary.className = running ? "btn primary wide big" : "btn primary wide big";
161 p.leave.textContent = running ? "Forfeit round" : "Leave table";
162
163 if (running) {
164 p.status.textContent = "Rising — cash out before the crash.";
165 p.status.className = "note win-text";
166 } else if (starting) {
167 p.status.textContent = "Starting the round…";
168 p.status.className = "note";
169 } else if (view.status === "cashed") {
170 p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`;
171 p.status.className = "note win-text";
172 } else if (view.status === "crashed") {
173 p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`;
174 p.status.className = "note error-text";
175 } else if (view.status === "forfeit") {
176 p.status.textContent = "Round forfeited.";
177 p.status.className = "note error-text";
178 } else {
179 p.status.textContent = "Ready. Start a round when you want.";
180 p.status.className = "note";
181 }
182
183 if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") {
184 p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`;
185 }
186
187 const commit = view.commit ? String(view.commit) : "";
188 p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet";
189
190 if (view.seed && commit) {
191 const token = ++verifyToken;
192 const seed = String(view.seed);
193 const crashPoint = Number(view.crash_point ?? 0);
194 void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => {
195 if (token !== verifyToken || !parts) return;
196 const ok = commitOk && pointOk;
197 parts.verify.className = `verify ${ok ? "ok" : "bad"}`;
198 parts.verify.textContent = ok
199 ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed`
200 : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`;
201 });
202 } else if (!live && !commit) {
203 p.verify.className = "verify";
204 p.verify.textContent = "Waiting for a round commitment.";
205 }
206
207 if (frameHandle !== undefined) {
208 cancelAnimationFrame(frameHandle);
209 frameHandle = undefined;
210 }
211 if (running) {
212 const loop = () => {
213 if (!parts || current?.status !== "running") {
214 frameHandle = undefined;
215 return;
216 }
217 update(parts, current, now);
218 frameHandle = requestAnimationFrame(loop);
219 };
220 frameHandle = requestAnimationFrame(loop);
221 } else {
222 update(p, view, now);
223 }
224}
225
226function currentMultiplier(view: View, now: Clock): number {
227 if (view.status === "running") {
228 const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000);
229 const local = Math.min(1000, Math.exp(GROWTH * elapsed));
230 return Math.max(local, Number(view.multiplier ?? 1));
231 }
232 if (view.status === "cashed") return Number(view.multiplier ?? 1);
233 if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1);
234 return 1;
235}
236
237function update(p: Parts, view: View, now: Clock): void {
238 const multiplier = currentMultiplier(view, now);
239 p.number.textContent = `${multiplier.toFixed(2)}×`;
240 p.number.className = `crash-number ${
241 view.status === "running"
242 ? "running"
243 : view.status === "cashed"
244 ? "cashed"
245 : view.status === "crashed"
246 ? "crashed"
247 : ""
248 }`;
249 p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier));
250 drawChart(p.canvas, view, multiplier, now);
251}
252
253function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void {
254 const width = canvas.clientWidth || 600;
255 const height = canvas.clientHeight || 360;
256 const dpr = window.devicePixelRatio || 1;
257 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
258 canvas.width = Math.round(width * dpr);
259 canvas.height = Math.round(height * dpr);
260 }
261 const ctx = canvas.getContext("2d");
262 if (!ctx) return;
263 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
264 ctx.clearRect(0, 0, width, height);
265
266 const padL = 54;
267 const padR = 16;
268 const padT = 18;
269 const padB = 34;
270 const plotW = Math.max(1, width - padL - padR);
271 const plotH = Math.max(1, height - padT - padB);
272
273 const elapsed =
274 view.status === "running"
275 ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000)
276 : multiplier > 1
277 ? Math.log(multiplier) / GROWTH
278 : 1;
279 const xMax = Math.max(5, elapsed * 1.08);
280 const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2);
281 const x = (seconds: number) => padL + (seconds / xMax) * plotW;
282 const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH;
283
284 ctx.strokeStyle = "#242e38";
285 ctx.fillStyle = "#8a98a6";
286 ctx.font = "11px ui-monospace, monospace";
287 ctx.lineWidth = 1;
288 for (let step = 0; step <= 4; step += 1) {
289 const value = 1 + ((yMax - 1) * step) / 4;
290 const py = y(value);
291 ctx.beginPath();
292 ctx.moveTo(padL, py);
293 ctx.lineTo(width - padR, py);
294 ctx.stroke();
295 ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4);
296 }
297 for (let step = 0; step <= 4; step += 1) {
298 const seconds = (xMax * step) / 4;
299 const px = x(seconds);
300 ctx.beginPath();
301 ctx.moveTo(px, padT);
302 ctx.lineTo(px, padT + plotH);
303 ctx.strokeStyle = "#1b232c";
304 ctx.stroke();
305 ctx.fillStyle = "#5b6773";
306 ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12);
307 }
308
309 const line =
310 view.status === "crashed"
311 ? "#e05a5a"
312 : view.status === "cashed"
313 ? "#4ec27b"
314 : "#e0b64a";
315
316 ctx.beginPath();
317 const samples = 180;
318 for (let index = 0; index <= samples; index += 1) {
319 const seconds = (elapsed * index) / samples;
320 const value = Math.min(yMax, Math.exp(GROWTH * seconds));
321 const px = x(seconds);
322 const py = y(value);
323 if (index === 0) ctx.moveTo(px, py);
324 else ctx.lineTo(px, py);
325 }
326 ctx.strokeStyle = line;
327 ctx.lineWidth = 2.4;
328 ctx.stroke();
329
330 const endX = x(elapsed);
331 const endY = y(Math.min(yMax, multiplier));
332 ctx.beginPath();
333 ctx.arc(endX, endY, 4.5, 0, Math.PI * 2);
334 ctx.fillStyle = line;
335 ctx.fill();
336
337 ctx.fillStyle = line;
338 ctx.font = "600 12px ui-monospace, monospace";
339 ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10));
340}
341
342async function verifyReveal(
343 seed: string,
344 commit: string,
345 crashPoint: number,
346): Promise<[boolean, boolean]> {
347 const bytes = new Uint8Array(seed.length / 2);
348 for (let index = 0; index < bytes.length; index += 1) {
349 bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16);
350 }
351 const digest = await crypto.subtle.digest("SHA-256", bytes);
352 const actual = [...new Uint8Array(digest)]
353 .map((byte) => byte.toString(16).padStart(2, "0"))
354 .join("");
355 const commitOk = actual === commit.toLowerCase();
356
357 let uniform = 0;
358 for (let index = 0; index < 8; index += 1) {
359 uniform = uniform * 256 + bytes[index];
360 }
361 uniform /= 2 ** 64;
362 const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform)));
363 const pointOk = Math.abs(point - crashPoint) < 1e-6;
364 return [commitOk, pointOk];
365}
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 @@
1export function byId<T extends HTMLElement = HTMLElement>(id: string): T {
2 const node = document.getElementById(id);
3 if (!node) throw new Error(`missing #${id}`);
4 return node as T;
5}
6
7export function node<K extends keyof HTMLElementTagNameMap>(
8 tag: K,
9 className?: string,
10 text?: string,
11): HTMLElementTagNameMap[K] {
12 const element = document.createElement(tag);
13 if (className) element.className = className;
14 if (text !== undefined) element.textContent = text;
15 return element;
16}
17
18export function clear(element: HTMLElement): void {
19 element.replaceChildren();
20}
21
22export function groupDigits(amount: number): string {
23 const negative = amount < 0;
24 const digits = String(Math.abs(Math.trunc(amount)));
25 let grouped = "";
26 for (let index = 0; index < digits.length; index += 1) {
27 if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009";
28 grouped += digits[index];
29 }
30 return negative ? `-${grouped}` : grouped;
31}
32
33export function money(amount: number | null | undefined): string {
34 if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr";
35 return `${groupDigits(amount)} Kr`;
36}
37
38export function signedMoney(amount: number): string {
39 return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`;
40}
41
42export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void {
43 const root = document.getElementById("toasts");
44 if (!root || !text) return;
45 const item = node("div", `toast ${tone}`, text);
46 root.append(item);
47 window.setTimeout(() => {
48 item.style.opacity = "0";
49 item.style.transition = "opacity 220ms ease";
50 window.setTimeout(() => item.remove(), 240);
51 }, 4200);
52 while (root.children.length > 4) root.firstElementChild?.remove();
53}
54
55export function toneFor(text: string): "info" | "good" | "bad" {
56 const lowered = text.toLowerCase();
57 if (
58 text.startsWith("ERROR") ||
59 lowered.includes("failed") ||
60 lowered.includes("lost") ||
61 lowered.includes("invalid") ||
62 lowered.includes("forfeit")
63 ) {
64 return "bad";
65 }
66 if (
67 text.startsWith("Logged in") ||
68 text.startsWith("Account created") ||
69 lowered.includes("won") ||
70 lowered.includes("cashed")
71 ) {
72 return "good";
73 }
74 return "info";
75}
76
77export async function sha256Hex(text: string): Promise<string> {
78 const bytes = new TextEncoder().encode(text);
79 const digest = await crypto.subtle.digest("SHA-256", bytes);
80 return [...new Uint8Array(digest)]
81 .map((byte) => byte.toString(16).padStart(2, "0"))
82 .join("");
83}
84
85export function shortHash(hash: string, keep = 10): string {
86 if (hash.length <= keep * 2) return hash;
87 return `${hash.slice(0, keep)}…${hash.slice(-keep)}`;
88}
89
90export function formatTime(raw: string): string {
91 // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC.
92 const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z");
93 if (Number.isNaN(parsed)) return raw;
94 return new Date(parsed).toLocaleString();
95}
96
97export function cardFace(card: string): { rank: string; suit: string; red: boolean } {
98 if (card === "?" || !card) return { rank: "?", suit: "", red: false };
99 const suit = card.slice(-1);
100 const rank = card.slice(0, -1);
101 const glyph =
102 suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit;
103 return { rank, suit: glyph, red: suit === "D" || suit === "H" };
104}
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 @@
1import type { View } from "./types";
2import { clear, formatTime, money, node, signedMoney } from "./dom";
3
4function summary(record: any): string {
5 const details = record.details ?? {};
6 switch (record.game) {
7 case "slots":
8 return String(details.win_type ?? "spin");
9 case "roulette":
10 return `${details.number ?? "?"} ${details.color ?? ""}`.trim();
11 case "blackjack":
12 return String(details.result ?? "");
13 case "crash": {
14 const result = String(details.result ?? "");
15 const point = details.crash_point;
16 return point === undefined || point === null ? result : `${result} @ ${Number(point).toFixed(2)}×`;
17 }
18 default:
19 return "";
20 }
21}
22
23export function renderHistory(root: HTMLElement, view: View): void {
24 clear(root);
25 const records: any[] = view.records ?? [];
26 if (records.length === 0) {
27 const empty = node("div", "board");
28 empty.append(
29 node("h2", undefined, "No plays yet"),
30 node("p", "muted", "Your settled plays will appear here."),
31 );
32 root.append(empty);
33 return;
34 }
35
36 const wrap = node("div", "table-wrap");
37 const table = node("table");
38 const head = node("thead");
39 const headRow = node("tr");
40 for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) {
41 headRow.append(node("th", undefined, label));
42 }
43 head.append(headRow);
44 const body = node("tbody");
45 for (const record of records) {
46 const row = node("tr");
47 row.append(node("td", "mono", formatTime(String(record.at ?? ""))));
48 row.append(node("td", undefined, String(record.game ?? "")));
49 row.append(node("td", "num", money(Number(record.bet ?? 0))));
50 row.append(node("td", "muted", summary(record)));
51 const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0);
52 const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net));
53 row.append(netCell);
54 row.append(node("td", "num", money(Number(record.balance_after ?? 0))));
55 body.append(row);
56 }
57 table.append(head, body);
58 wrap.append(table);
59 root.append(wrap);
60}
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 @@
1import { byId, money, node, sha256Hex, toast, toneFor } from "./dom";
2import { Gateway } from "./net";
3import { renderBlackjack } from "./blackjack";
4import { renderCrash } from "./crash";
5import { renderHistory } from "./history";
6import { renderRoulette } from "./roulette";
7import { renderSlots } from "./slots";
8import type { View } from "./types";
9
10const authScreen = byId("auth-screen");
11const lobbyScreen = byId("lobby-screen");
12const gameScreen = byId("game-screen");
13const historyScreen = byId("history-screen");
14const sessionBar = byId("session-bar");
15const accountName = byId("account-name");
16const accountBalance = byId("account-balance");
17const gameGrid = byId("game-grid");
18const gameBoard = byId("game-board");
19const gameSide = byId("game-side");
20const gameTitle = byId("game-title");
21const gameBadge = byId("game-badge");
22const historyBody = byId("history-body");
23const authForm = byId<HTMLFormElement>("auth-form");
24const authUser = byId<HTMLInputElement>("auth-user");
25const authPass = byId<HTMLInputElement>("auth-pass");
26const authError = byId("auth-error");
27const authSubmit = byId<HTMLButtonElement>("auth-submit");
28const tabLogin = byId<HTMLButtonElement>("tab-login");
29const tabRegister = byId<HTMLButtonElement>("tab-register");
30const reconnectOverlay = byId("reconnect-overlay");
31const reconnectMessage = byId("reconnect-message");
32const replacedOverlay = byId("replaced-overlay");
33
34let view: View | null = null;
35let currentUser: string | null = null;
36let lastBalance: number | null = null;
37let authMode: "login" | "register" = "login";
38let infoFor: string | null = null;
39let serverOffset = 0;
40let reconnectAttempts = 0;
41const bets = new Map<string, number>([
42 ["slots", 10],
43 ["roulette", 10],
44 ["blackjack", 10],
45 ["crash", 10],
46]);
47
48const titles: Record<string, string> = {
49 slots: "Slots",
50 roulette: "Roulette",
51 blackjack: "Blackjack",
52 crash: "Crash",
53};
54
55const descriptions: Record<string, string> = {
56 slots: "Five reels, five paylines. Match symbols from the left.",
57 roulette: "European single-zero wheel with inside and outside bets.",
58 blackjack: "Six-deck shoe. Blackjack pays 3:2.",
59 crash: "A rising multiplier. Cash out before it crashes.",
60};
61
62const basePath = location.pathname.includes("/play/")
63 ? location.pathname.slice(0, location.pathname.indexOf("/play/"))
64 : "";
65const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`;
66
67const gateway = new Gateway(wsUrl, {
68 onHello(hello) {
69 serverOffset = hello.time - Date.now();
70 currentUser = hello.user ?? null;
71 gameBadge.textContent = `v${hello.version}`;
72 },
73 onView(next) {
74 view = next;
75 // Game and history views carry no account field; the lobby is the source
76 // of truth for who is signed in. A null lobby user is a logout or an
77 // expired session.
78 if (typeof next.balance === "number") lastBalance = next.balance;
79 if (next.screen === "lobby" && "user" in next) {
80 currentUser = (next.user as string | null) ?? null;
81 if (currentUser) authPass.value = "";
82 else lastBalance = null;
83 }
84 reconnectAttempts = 0;
85 reconnectOverlay.hidden = true;
86 render();
87 if (currentUser && view.balance === null && infoFor !== currentUser) {
88 infoFor = currentUser;
89 gateway.send("Info");
90 }
91 if (!currentUser) infoFor = null;
92 },
93 onNotice(text) {
94 if (
95 text.startsWith("Connected to:") ||
96 text.startsWith("Fingerprint:") ||
97 text.startsWith("User:") ||
98 text.startsWith("Account:") ||
99 text.startsWith("Balance:")
100 ) {
101 return;
102 }
103 toast(text, toneFor(text));
104 if (text.startsWith("Account created")) {
105 setAuthMode("login");
106 authPass.value = "";
107 authUser.focus();
108 }
109 authSubmit.disabled = false;
110 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account";
111 },
112 onError(text) {
113 toast(text, "bad");
114 authSubmit.disabled = false;
115 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account";
116 },
117 onReplaced() {
118 replacedOverlay.hidden = false;
119 },
120 onPong() {
121 /* The gateway answered; the socket is alive. */
122 },
123 onOpen() {
124 reconnectOverlay.hidden = true;
125 },
126 onClosed(willRetry) {
127 if (replacedOverlay.hidden === false) return;
128 if (!willRetry) {
129 reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again.";
130 reconnectOverlay.hidden = false;
131 return;
132 }
133 reconnectAttempts += 1;
134 reconnectMessage.textContent =
135 reconnectAttempts > 1
136 ? "Still trying to reach the game server…"
137 : "Trying to reach the game server…";
138 reconnectOverlay.hidden = false;
139 },
140});
141
142function send(command: unknown): void {
143 gateway.send(command);
144}
145
146function serverNow(): number {
147 return Date.now() + serverOffset;
148}
149
150function setAuthMode(mode: "login" | "register"): void {
151 authMode = mode;
152 tabLogin.classList.toggle("active", mode === "login");
153 tabRegister.classList.toggle("active", mode === "register");
154 authSubmit.textContent = mode === "login" ? "Log in" : "Create account";
155 authPass.autocomplete = mode === "login" ? "current-password" : "new-password";
156 authError.textContent = "";
157}
158
159function show(screen: "auth" | "lobby" | "game" | "history"): void {
160 authScreen.hidden = screen !== "auth";
161 lobbyScreen.hidden = screen !== "lobby";
162 gameScreen.hidden = screen !== "game";
163 historyScreen.hidden = screen !== "history";
164}
165
166function setBalance(balance: number | null | undefined): void {
167 accountBalance.textContent = money(balance ?? null);
168}
169
170function render(): void {
171 if (!view) return;
172 const user = currentUser;
173 if (user) {
174 sessionBar.hidden = false;
175 accountName.textContent = String(user);
176 } else {
177 sessionBar.hidden = true;
178 }
179
180 setBalance(lastBalance);
181
182 if (!user) {
183 show("auth");
184 authError.textContent = view.login_error ? String(view.login_error) : "";
185 if (view.screen === "lobby" && view.connected === false) {
186 authError.textContent = "The game server is not reachable yet. Retrying…";
187 }
188 return;
189 }
190
191 switch (view.screen) {
192 case "lobby":
193 show("lobby");
194 renderLobby(view);
195 break;
196 case "history":
197 show("history");
198 renderHistory(historyBody, view);
199 break;
200 case "slots":
201 case "roulette":
202 case "blackjack":
203 case "crash":
204 show("game");
205 gameTitle.textContent = titles[view.screen] ?? view.screen;
206 gameBadge.textContent = badge(view);
207 if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send);
208 else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send);
209 else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send);
210 else renderCrash(gameBoard, gameSide, view, send, { now: serverNow });
211 break;
212 default:
213 show("auth");
214 }
215}
216
217function badge(view: View): string {
218 if (view.screen === "crash") return String(view.status ?? "");
219 if (view.screen === "blackjack") return String(view.status ?? "");
220 if (view.screen === "slots") return String(view.phase ?? "");
221 if (view.screen === "roulette") return String(view.phase ?? "");
222 return "";
223}
224
225function renderLobby(lobby: View): void {
226 gameGrid.replaceChildren();
227 const list: [string, string][] = lobby.games ?? [];
228 for (const [id, name] of list) {
229 const card = node("article", `game-card ${id}`);
230 card.append(node("h3", undefined, name || titles[id] || id));
231 card.append(node("p", undefined, descriptions[id] ?? "Play a round."));
232
233 const row = node("div", "row wrap");
234 const label = node("label", "field");
235 label.style.flex = "1";
236 label.append(node("span", "label", "Bet"));
237 const input = node("input", "input bet-input") as HTMLInputElement;
238 input.type = "number";
239 input.min = "1";
240 input.step = "10";
241 input.value = String(bets.get(id) ?? 10);
242 input.addEventListener("change", () => {
243 const value = Math.max(1, Math.floor(Number(input.value) || 1));
244 input.value = String(value);
245 bets.set(id, value);
246 });
247 label.append(input);
248
249 const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play");
250 play.addEventListener("click", () => {
251 const bet = Math.max(1, Math.floor(Number(input.value) || 1));
252 bets.set(id, bet);
253 send({ Play: { game: id, bet } });
254 });
255 row.append(label, play);
256 card.append(row);
257 gameGrid.append(card);
258 }
259
260 if (list.length === 0) {
261 gameGrid.append(node("p", "muted", "The server did not advertise any games."));
262 }
263}
264
265// Auth tabs and form -------------------------------------------------------
266tabLogin.addEventListener("click", () => setAuthMode("login"));
267tabRegister.addEventListener("click", () => setAuthMode("register"));
268
269authForm.addEventListener("submit", async (event) => {
270 event.preventDefault();
271 const user = authUser.value.trim();
272 const password = authPass.value;
273 if (!user || !password) {
274 authError.textContent = "Enter a username and a password.";
275 return;
276 }
277 authSubmit.disabled = true;
278 authSubmit.textContent = "Working…";
279 authError.textContent = "";
280 try {
281 const credential = await sha256Hex(password);
282 if (authMode === "login") {
283 send({ Login: { user, credential } });
284 } else {
285 send({ Register: { user, credential } });
286 }
287 } catch {
288 authError.textContent = "This browser cannot hash the password (needs a secure context).";
289 authSubmit.disabled = false;
290 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account";
291 }
292});
293
294// Chrome and navigation ----------------------------------------------------
295byId("logout").addEventListener("click", () => send("Logout"));
296byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } }));
297byId("refresh-lobby").addEventListener("click", () => send("Info"));
298byId("history-back").addEventListener("click", () => send("ShowLobby"));
299byId("leave-game").addEventListener("click", () => {
300 if (!view) return;
301 const live =
302 (view.screen === "blackjack" && view.status === "player") ||
303 (view.screen === "crash" && view.status === "running");
304 if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return;
305 send("Leave");
306});
307byId("reload-replaced").addEventListener("click", () => location.reload());
308byId("reload-reconnect").addEventListener("click", () => location.reload());
309
310window.addEventListener("resize", () => {
311 window.clearTimeout((window as any).__resizeTimer);
312 (window as any).__resizeTimer = window.setTimeout(() => {
313 if (view) render();
314 }, 140);
315});
316
317// A periodic ping keeps the gateway session visibly alive and refreshes the
318// server clock estimate without changing game state.
319window.setInterval(() => {
320 if (currentUser) gateway.ping();
321}, 25_000);
322
323gateway.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 @@
1import type { Handlers } from "./types";
2
3const MAX_ATTEMPTS = 6;
4
5/**
6 * Browser side of the gateway socket.
7 *
8 * The cookie is HttpOnly and handled by the browser; this class never reads or
9 * stores a credential. A transient close reconnects inside the gateway's grace
10 * window; a deliberate takeover stops for good.
11 */
12export class Gateway {
13 private socket: WebSocket | null = null;
14 private stopped = false;
15 private replaced = false;
16 private attempts = 0;
17 private timer: number | undefined;
18
19 constructor(
20 private readonly url: string,
21 private readonly handlers: Handlers,
22 ) {}
23
24 connect(): void {
25 if (this.stopped) return;
26 this.clearTimer();
27
28 let socket: WebSocket;
29 try {
30 socket = new WebSocket(this.url);
31 } catch {
32 const retrying = this.scheduleReconnect();
33 if (!retrying) this.handlers.onClosed(false);
34 return;
35 }
36 this.socket = socket;
37
38 socket.addEventListener("open", () => {
39 this.attempts = 0;
40 this.handlers.onOpen();
41 });
42
43 socket.addEventListener("message", (event) => {
44 let message: any;
45 try {
46 message = JSON.parse(String(event.data));
47 } catch {
48 return;
49 }
50 switch (message.type) {
51 case "hello":
52 this.handlers.onHello(message);
53 break;
54 case "view":
55 this.handlers.onView(message.view);
56 break;
57 case "notice":
58 this.handlers.onNotice(String(message.text ?? ""));
59 break;
60 case "error":
61 this.handlers.onError(String(message.text ?? ""));
62 break;
63 case "replaced":
64 this.replaced = true;
65 this.handlers.onReplaced();
66 break;
67 case "pong":
68 this.handlers.onPong();
69 break;
70 }
71 });
72
73 socket.addEventListener("close", () => {
74 const wasThisSocket = this.socket === socket;
75 if (wasThisSocket) this.socket = null;
76 if (this.replaced || this.stopped) {
77 this.handlers.onClosed(false);
78 return;
79 }
80 const retrying = this.scheduleReconnect();
81 this.handlers.onClosed(retrying);
82 });
83
84 socket.addEventListener("error", () => {
85 // The close event follows; reconnect is scheduled there.
86 });
87 }
88
89 send(command: unknown): void {
90 if (this.socket?.readyState !== WebSocket.OPEN) return;
91 this.socket.send(JSON.stringify({ type: "command", command }));
92 }
93
94 ping(): void {
95 if (this.socket?.readyState === WebSocket.OPEN) {
96 this.socket.send(JSON.stringify({ type: "ping" }));
97 }
98 }
99
100 close(): void {
101 this.stopped = true;
102 this.clearTimer();
103 this.socket?.close();
104 }
105
106 private scheduleReconnect(): boolean {
107 if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false;
108 this.attempts += 1;
109 const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000);
110 this.timer = window.setTimeout(() => this.connect(), delay);
111 return true;
112 }
113
114 private clearTimer(): void {
115 if (this.timer !== undefined) {
116 window.clearTimeout(this.timer);
117 this.timer = undefined;
118 }
119 }
120}
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 @@
1import type { View } from "./types";
2import { clear, money, node } from "./dom";
3
4type Zone = {
5 x: number;
6 y: number;
7 w: number;
8 h: number;
9 target: any;
10 label: string;
11 kind: "number" | "zero" | "dozen" | "column" | "outside";
12 number?: number;
13};
14
15type Hotspot = {
16 x: number;
17 y: number;
18 radius: number;
19 target: any;
20 rank: number;
21};
22
23type Layout = {
24 width: number;
25 height: number;
26 cellW: number;
27 cellH: number;
28 zeroX: number;
29 zeroW: number;
30 gridX: number;
31 gridY: number;
32 gridW: number;
33 columnX: number;
34 dozensY: number;
35 outsideY: number;
36 outsideH: number;
37 zones: Zone[];
38 hotspots: Hotspot[];
39};
40
41const RED = "#b0313a";
42const BLACK = "#151b21";
43const ZERO = "#106b45";
44const FELT = "#0d5c39";
45const FELT_DARK = "#09472c";
46const FELT_LINE = "#1f7d52";
47const TEXT = "#ecf7f0";
48const GOLD = "#e0b64a";
49
50function colorOf(number: number): string {
51 if (number === 0) return "green";
52 const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36];
53 return reds.includes(number) ? "red" : "black";
54}
55
56function layout(width: number, height: number): Layout {
57 const pad = 14;
58 const zeroW = Math.max(38, width * 0.055);
59 const columnW = Math.max(34, width * 0.05);
60 const outsideH = Math.max(30, height * 0.07);
61 const gap = 6;
62 const gridW = width - pad * 2 - zeroW - columnW;
63 const cellW = gridW / 12;
64 const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3;
65 const gridX = pad + zeroW;
66 const gridY = pad;
67 const dozensY = gridY + cellH * 3 + gap;
68 const outsideY = dozensY + outsideH + gap;
69 const zones: Zone[] = [];
70
71 for (let column = 0; column < 12; column += 1) {
72 for (let row = 0; row < 3; row += 1) {
73 const number = column * 3 + (3 - row);
74 zones.push({
75 x: gridX + column * cellW,
76 y: gridY + row * cellH,
77 w: cellW,
78 h: cellH,
79 target: { bet: "straight", number },
80 label: String(number),
81 kind: "number",
82 number,
83 });
84 }
85 }
86
87 zones.push({
88 x: pad,
89 y: gridY,
90 w: zeroW,
91 h: cellH * 3,
92 target: { bet: "straight", number: 0 },
93 label: "0",
94 kind: "zero",
95 number: 0,
96 });
97
98 for (let top = 0; top < 3; top += 1) {
99 const index = 2 - top;
100 zones.push({
101 x: gridX + gridW,
102 y: gridY + top * cellH,
103 w: columnW,
104 h: cellH,
105 target: { bet: "column", index },
106 label: "2:1",
107 kind: "column",
108 });
109 }
110
111 for (let index = 0; index < 3; index += 1) {
112 zones.push({
113 x: gridX + (gridW / 3) * index,
114 y: dozensY,
115 w: gridW / 3,
116 h: outsideH,
117 target: { bet: "dozen", index },
118 label: ["1st 12", "2nd 12", "3rd 12"][index],
119 kind: "dozen",
120 });
121 }
122
123 const outside = [
124 { kind: "low", label: "1–18" },
125 { kind: "even", label: "EVEN" },
126 { kind: "red", label: "RED" },
127 { kind: "black", label: "BLACK" },
128 { kind: "odd", label: "ODD" },
129 { kind: "high", label: "19–36" },
130 ];
131 outside.forEach((item, index) => {
132 zones.push({
133 x: gridX + (gridW / 6) * index,
134 y: outsideY,
135 w: gridW / 6,
136 h: outsideH,
137 target: { bet: "outside", kind: item.kind },
138 label: item.label,
139 kind: "outside",
140 });
141 });
142
143 // Split and corner hotspots sit on the lines between cells, like the real
144 // felt (and the desktop fine lattice). Corners rank ahead of splits.
145 const hotspots: Hotspot[] = [];
146 const cellCenter = (number: number) => {
147 const cell = zones.find((zone) => zone.kind === "number" && zone.number === number);
148 return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null;
149 };
150
151 for (let column = 0; column < 11; column += 1) {
152 for (let row = 0; row < 2; row += 1) {
153 const top = column * 3 + (3 - row);
154 hotspots.push({
155 x: gridX + (column + 1) * cellW,
156 y: gridY + (row + 1) * cellH,
157 radius: 12,
158 rank: 0,
159 target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] },
160 });
161 }
162 }
163
164 for (let column = 0; column < 12; column += 1) {
165 for (let row = 0; row < 2; row += 1) {
166 const top = column * 3 + (3 - row);
167 const center = cellCenter(top);
168 if (!center) continue;
169 hotspots.push({
170 x: center.x,
171 y: gridY + (row + 1) * cellH,
172 radius: 10,
173 rank: 1,
174 target: { bet: "split", a: top, b: top - 1 },
175 });
176 }
177 }
178
179 for (let column = 0; column < 11; column += 1) {
180 for (let row = 0; row < 3; row += 1) {
181 const left = column * 3 + (3 - row);
182 hotspots.push({
183 x: gridX + (column + 1) * cellW,
184 y: gridY + row * cellH + cellH / 2,
185 radius: 10,
186 rank: 1,
187 target: { bet: "split", a: left, b: left + 3 },
188 });
189 }
190 }
191
192 for (let row = 0; row < 3; row += 1) {
193 const number = 3 - row;
194 hotspots.push({
195 x: gridX,
196 y: gridY + row * cellH + cellH / 2,
197 radius: 10,
198 rank: 1,
199 target: { bet: "split", a: 0, b: number },
200 });
201 }
202
203 return {
204 width,
205 height,
206 cellW,
207 cellH,
208 zeroX: pad,
209 zeroW,
210 gridX,
211 gridY,
212 gridW,
213 columnX: gridX + gridW,
214 dozensY,
215 outsideY,
216 outsideH,
217 zones,
218 hotspots,
219 };
220}
221
222function roundRect(
223 ctx: CanvasRenderingContext2D,
224 x: number,
225 y: number,
226 w: number,
227 h: number,
228 radius: number,
229): void {
230 const r = Math.min(radius, w / 2, h / 2);
231 ctx.beginPath();
232 ctx.moveTo(x + r, y);
233 ctx.arcTo(x + w, y, x + w, y + h, r);
234 ctx.arcTo(x + w, y + h, x, y + h, r);
235 ctx.arcTo(x, y + h, x, y, r);
236 ctx.arcTo(x, y, x + w, y, r);
237 ctx.closePath();
238}
239
240function draw(canvas: HTMLCanvasElement, view: View): Layout {
241 const width = canvas.clientWidth || 800;
242 const height = canvas.clientHeight || 540;
243 const dpr = window.devicePixelRatio || 1;
244 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
245 canvas.width = Math.round(width * dpr);
246 canvas.height = Math.round(height * dpr);
247 }
248 const ctx = canvas.getContext("2d");
249 const map = layout(width, height);
250 if (!ctx) return map;
251 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
252 ctx.clearRect(0, 0, width, height);
253 ctx.fillStyle = FELT_DARK;
254 roundRect(ctx, 0, 0, width, height, 10);
255 ctx.fill();
256
257 for (const zone of map.zones) {
258 let fill = FELT;
259 if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK;
260 else if (zone.kind === "zero") fill = ZERO;
261 ctx.fillStyle = fill;
262 ctx.strokeStyle = FELT_LINE;
263 ctx.lineWidth = 1;
264 roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3);
265 ctx.fill();
266 ctx.stroke();
267
268 ctx.fillStyle = TEXT;
269 ctx.textAlign = "center";
270 ctx.textBaseline = "middle";
271 if (zone.kind === "number" || zone.kind === "zero") {
272 ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`;
273 } else {
274 ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`;
275 }
276 ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2);
277 }
278
279 drawChips(ctx, map, view);
280 return map;
281}
282
283function targetKey(target: any): string {
284 return JSON.stringify(target);
285}
286
287function centerFor(layout: Layout, target: any): { x: number; y: number } | null {
288 const key = targetKey(target);
289 const exact = layout.zones.find((zone) => targetKey(zone.target) === key);
290 if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 };
291
292 // Split and corner bets are reachable from the desktop's fine lattice. A
293 // browser session can still show them: average the covered number cells.
294 if (target?.bet === "split" || target?.bet === "corner") {
295 const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers;
296 const cells = layout.zones.filter(
297 (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!),
298 );
299 if (cells.length > 0) {
300 return {
301 x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length,
302 y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length,
303 };
304 }
305 }
306 return null;
307}
308
309function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void {
310 const chips: any[] = view.bets?.chips ?? [];
311 for (const chip of chips) {
312 const center = centerFor(map, chip.target);
313 if (!center) continue;
314 const radius = Math.max(12, Math.min(18, map.cellW * 0.26));
315 ctx.beginPath();
316 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
317 ctx.fillStyle = GOLD;
318 ctx.fill();
319 ctx.strokeStyle = "#7a5a12";
320 ctx.lineWidth = 2;
321 ctx.stroke();
322 ctx.fillStyle = "#221a05";
323 ctx.textAlign = "center";
324 ctx.textBaseline = "middle";
325 ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`;
326 ctx.fillText(String(chip.amount), center.x, center.y + 0.5);
327 }
328}
329
330export function renderRoulette(
331 board: HTMLElement,
332 side: HTMLElement,
333 view: View,
334 send: (command: unknown) => void,
335): void {
336 clear(board);
337 clear(side);
338
339 const spinning = view.phase === "spinning";
340 board.append(node("h2", undefined, "Roulette"));
341 board.append(
342 node(
343 "p",
344 "note",
345 "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.",
346 ),
347 );
348
349 const wrap = node("div", "roulette-wrap");
350 const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement;
351 wrap.append(canvas);
352
353 if (view.phase === "result" && view.number !== null && view.number !== undefined) {
354 const result = node("div", `wheel-result ${view.color ?? ""}`);
355 result.append(node("div", "label", "Result"));
356 result.append(node("div", "num", String(view.number)));
357 const winnings = Number(view.winnings ?? 0);
358 result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win"));
359 wrap.append(result);
360 }
361 board.append(wrap);
362
363 // The canvas must be in the document before its client size is read.
364 const map = draw(canvas, view);
365 let current = map;
366
367 const hit = (clientX: number, clientY: number): Zone | null => {
368 const rect = canvas.getBoundingClientRect();
369 const x = clientX - rect.left;
370 const y = clientY - rect.top;
371
372 let best: Hotspot | null = null;
373 for (const hotspot of current.hotspots) {
374 const distance = Math.hypot(x - hotspot.x, y - hotspot.y);
375 if (distance > hotspot.radius) continue;
376 if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) {
377 best = hotspot;
378 }
379 }
380 if (best) {
381 return {
382 x: best.x - best.radius,
383 y: best.y - best.radius,
384 w: best.radius * 2,
385 h: best.radius * 2,
386 target: best.target,
387 label: "",
388 kind: "number",
389 };
390 }
391
392 for (let index = current.zones.length - 1; index >= 0; index -= 1) {
393 const zone = current.zones[index];
394 if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) {
395 return zone;
396 }
397 }
398 return null;
399 };
400
401 canvas.addEventListener("click", (event) => {
402 if (spinning) return;
403 const zone = hit(event.clientX, event.clientY);
404 if (zone) send({ PlaceBetAt: zone.target });
405 });
406 canvas.addEventListener("contextmenu", (event) => {
407 event.preventDefault();
408 if (spinning) return;
409 const zone = hit(event.clientX, event.clientY);
410 if (zone) send({ RemoveBetAt: zone.target });
411 });
412 canvas.addEventListener("mousemove", (event) => {
413 const zone = hit(event.clientX, event.clientY);
414 canvas.style.cursor = zone && !spinning ? "pointer" : "default";
415 });
416 new ResizeObserver(() => {
417 current = draw(canvas, view);
418 }).observe(canvas);
419
420 // Sidebar
421 side.append(node("div", "label", "Roulette"));
422 side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin."));
423
424 side.append(node("div", "label", "Chip"));
425 const chipRow = node("div", "chip-row");
426 for (const value of [10, 25, 50, 100, 500]) {
427 const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value));
428 button.disabled = spinning;
429 button.addEventListener("click", () => send({ SetChip: value }));
430 chipRow.append(button);
431 }
432 side.append(chipRow);
433
434 const total = (view.bets?.chips ?? []).reduce(
435 (sum: number, chip: any) => sum + Number(chip.amount ?? 0),
436 0,
437 );
438 const totalRow = node("div", "stat");
439 totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total)));
440 side.append(totalRow);
441
442 if (view.balance !== null && view.balance !== undefined) {
443 const balanceRow = node("div", "stat");
444 balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance))));
445 side.append(balanceRow);
446 }
447
448 if (view.error) side.append(node("p", "error-text", String(view.error)));
449
450 const status = node("p", "note");
451 if (spinning) status.textContent = "Spinning…";
452 else if (view.phase === "result") {
453 const winnings = Number(view.winnings ?? 0);
454 status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`;
455 status.className = winnings > 0 ? "note win-text" : "note";
456 } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip.";
457 side.append(status);
458
459 const actions = node("div", "actions");
460 const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL");
461 spin.disabled = spinning || total === 0;
462 spin.addEventListener("click", () => send("Spin"));
463 const clearBets = node("button", "btn wide", "Clear bets");
464 clearBets.disabled = spinning || total === 0;
465 clearBets.addEventListener("click", () => send("ClearBets"));
466 const leave = node("button", "btn ghost wide", "Leave table");
467 leave.disabled = spinning;
468 leave.addEventListener("click", () => {
469 if (spinning) return;
470 send("Leave");
471 });
472 actions.append(spin, clearBets, leave);
473 side.append(actions);
474}
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 @@
1import type { View } from "./types";
2import { clear, money, node } from "./dom";
3
4const REELS = 5;
5const ROWS = 3;
6const SYMBOLS = "CLOGDS";
7const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"];
8
9let spinUntil = 0;
10let animation: number | undefined;
11let latest: View | null = null;
12let latestBoard: HTMLElement | null = null;
13
14export function requestSlotsSpin(): void {
15 spinUntil = Date.now() + 900;
16}
17
18function spinning(view: View): boolean {
19 return view.phase === "spinning" || Date.now() < spinUntil;
20}
21
22export function renderSlots(
23 board: HTMLElement,
24 side: HTMLElement,
25 view: View,
26 send: (command: unknown) => void,
27): void {
28 latest = view;
29 latestBoard = board;
30 if (animation !== undefined) {
31 window.clearInterval(animation);
32 animation = undefined;
33 }
34
35 const active = spinning(view);
36 clear(board);
37 clear(side);
38
39 const title = node("h2", undefined, "Slots");
40 const note = node("p", "note", "Match three or more symbols from the left. Five paylines.");
41 board.append(title, note);
42
43 const reels = node("div", "reels");
44 const tiles: HTMLElement[] = [];
45 const flat = String(view.reels ?? "");
46 for (let column = 0; column < REELS; column += 1) {
47 const reel = node("div", "reel");
48 for (let row = 0; row < ROWS; row += 1) {
49 const index = column * ROWS + row;
50 const symbol = active
51 ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length]
52 : flat.length === REELS * ROWS
53 ? flat[index]
54 : flat.length === REELS
55 ? flat[column]
56 : SYMBOLS[(index * 5 + column) % SYMBOLS.length];
57 const tile = node("div", "tile", symbol);
58 tile.classList.add(`sym-${symbol}`);
59 if (active) tile.classList.add("spinning");
60 if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win");
61 tiles.push(tile);
62 reel.append(tile);
63 }
64 reels.append(reel);
65 }
66 board.append(reels);
67
68 const lines = node("div", "win-lines");
69 if (!active && view.phase === "result") {
70 const winnings = Number(view.winnings ?? 0);
71 if (winnings > 0) {
72 const names = (view.win_lines ?? [])
73 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`)
74 .join(" · ");
75 lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`;
76 lines.classList.add("win-text");
77 } else {
78 lines.textContent = "No win — spin again.";
79 }
80 } else if (active) {
81 lines.textContent = "Spinning…";
82 } else {
83 lines.textContent = "Pick a bet and spin.";
84 }
85 board.append(lines);
86
87 if (active) {
88 let tick = 0;
89 animation = window.setInterval(() => {
90 tick += 1;
91 for (let index = 0; index < tiles.length; index += 1) {
92 const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length];
93 tiles[index].textContent = symbol;
94 tiles[index].className = `tile spinning sym-${symbol}`;
95 }
96 if (Date.now() >= spinUntil && latest && latestBoard) {
97 window.clearInterval(animation);
98 animation = undefined;
99 renderSlots(latestBoard, side, latest, send);
100 }
101 }, 70);
102 }
103
104 // Sidebar
105 side.append(node("div", "label", "Slots"));
106 side.append(node("p", "note", "5 reels × 3 rows, 5 paylines."));
107
108 const betRow = node("div", "row");
109 const minus = node("button", "btn small", "−10");
110 const bet = node("strong", "mono", money(Number(view.bet ?? 0)));
111 const plus = node("button", "btn small", "+10");
112 minus.disabled = active;
113 plus.disabled = active;
114 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
115 plus.addEventListener("click", () => send({ AdjustBet: 10 }));
116 betRow.append(minus, bet, plus);
117 side.append(node("div", "label", "Bet"), betRow);
118
119 if (view.error) {
120 side.append(node("p", "error-text", String(view.error)));
121 }
122
123 const status = node("p", "note");
124 if (active) status.textContent = "Spinning…";
125 else if (view.phase === "result") {
126 const winnings = Number(view.winnings ?? 0);
127 status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin.";
128 if (winnings > 0) status.classList.add("win-text");
129 } else status.textContent = "Ready when you are.";
130 side.append(status);
131
132 const actions = node("div", "actions");
133 const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN");
134 spin.disabled = active;
135 spin.addEventListener("click", () => {
136 requestSlotsSpin();
137 send("Spin");
138 });
139 const leave = node("button", "btn ghost wide", "Leave machine");
140 leave.disabled = active;
141 leave.addEventListener("click", () => send("Leave"));
142 actions.append(spin, leave);
143 side.append(actions);
144}
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 @@
1export type View = {
2 screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash";
3 [key: string]: any;
4};
5
6export type Hello = {
7 type: "hello";
8 version: string;
9 grace_seconds: number;
10 /** Signed-in user restored after a refresh, if any. */
11 user?: string | null;
12 /** Estimated game-server Unix time in milliseconds. */
13 time: number;
14};
15
16export type Send = (command: unknown) => void;
17
18export type Handlers = {
19 onHello: (hello: Hello) => void;
20 onView: (view: View) => void;
21 onNotice: (text: string) => void;
22 onError: (text: string) => void;
23 onReplaced: () => void;
24 onPong: () => void;
25 onOpen: () => void;
26 onClosed: (willRetry: boolean) => void;
27};
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 @@
1:root {
2 --bg: #0a0e12;
3 --surface: #11171d;
4 --raised: #171f27;
5 --hover: #1d2731;
6 --line: #242e38;
7 --text: #d9e2ea;
8 --dim: #8a98a6;
9 --faint: #5b6773;
10 --accent: #4fb8c9;
11 --win: #4ec27b;
12 --loss: #e05a5a;
13 --gold: #e0b64a;
14 --felt: #0d5c39;
15 --felt-dark: #09472c;
16 --felt-line: #1f7d52;
17 --felt-text: #ecf7f0;
18 --red: #b0313a;
19 --black: #151b21;
20 --zero: #106b45;
21 --radius-sm: 4px;
22 --radius-md: 8px;
23 --radius-lg: 12px;
24 --sans: "Open Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
25 --mono: "Hack", ui-monospace, "SFMono-Regular", "Cascadia Code", monospace;
26}
27
28* { box-sizing: border-box; }
29html, body { height: 100%; }
30body {
31 margin: 0;
32 background:
33 radial-gradient(1100px 600px at 80% -10%, rgba(79, 184, 201, 0.06), transparent 60%),
34 var(--bg);
35 color: var(--text);
36 font-family: var(--sans);
37 font-size: 14px;
38 line-height: 1.5;
39 -webkit-font-smoothing: antialiased;
40}
41[hidden] { display: none !important; }
42
43a { color: var(--accent); text-decoration: none; }
44a:hover { text-decoration: underline; }
45button, input { font: inherit; color: inherit; }
46button { cursor: pointer; }
47button:disabled { cursor: not-allowed; opacity: 0.45; }
48
49.shell { min-height: 100%; display: flex; flex-direction: column; }
50
51.topbar {
52 display: flex;
53 align-items: center;
54 gap: 16px;
55 min-height: 64px;
56 padding: 0 24px;
57 border-bottom: 1px solid var(--line);
58 background: rgba(10, 14, 18, 0.86);
59 backdrop-filter: blur(10px);
60 position: sticky;
61 top: 0;
62 z-index: 20;
63}
64.brand {
65 color: var(--text);
66 font-family: var(--mono);
67 font-size: 19px;
68 letter-spacing: 0.04em;
69 white-space: nowrap;
70}
71.brand span { color: var(--win); }
72.brand:hover { text-decoration: none; }
73.tagline {
74 color: var(--faint);
75 font-family: var(--mono);
76 font-size: 11px;
77 text-transform: uppercase;
78 letter-spacing: 0.12em;
79}
80.spacer { flex: 1; }
81.session { display: flex; align-items: center; gap: 12px; }
82.session .who { color: var(--dim); font-family: var(--mono); font-size: 12px; }
83.session .who strong { color: var(--text); font-weight: 600; }
84.balance {
85 color: var(--win);
86 font-family: var(--mono);
87 font-size: 16px;
88 padding: 4px 10px;
89 border: 1px solid var(--line);
90 border-radius: var(--radius-sm);
91 background: var(--surface);
92 white-space: nowrap;
93}
94
95main { width: min(1240px, 100%); margin: 0 auto; padding: 28px 24px 64px; flex: 1; }
96
97h1, h2, h3 { margin: 0; font-weight: 600; }
98h1 { font-size: 26px; letter-spacing: -0.01em; }
99h2 { font-size: 18px; }
100.muted { color: var(--dim); }
101.faint { color: var(--faint); }
102.mono { font-family: var(--mono); }
103.label {
104 font-family: var(--mono);
105 font-size: 10px;
106 text-transform: uppercase;
107 letter-spacing: 0.16em;
108 color: var(--dim);
109}
110.error-text { color: var(--loss); }
111.win-text { color: var(--win); }
112
113.btn {
114 border: 1px solid var(--line);
115 background: var(--raised);
116 color: var(--text);
117 padding: 9px 14px;
118 border-radius: var(--radius-md);
119 line-height: 1.2;
120 transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
121}
122.btn:hover:not(:disabled) { background: var(--hover); border-color: #33404d; }
123.btn:active:not(:disabled) { transform: translateY(1px); }
124.btn.primary {
125 background: var(--win);
126 border-color: var(--win);
127 color: #06120b;
128 font-weight: 700;
129}
130.btn.primary:hover:not(:disabled) { filter: brightness(1.08); }
131.btn.accent {
132 background: rgba(79, 184, 201, 0.14);
133 border-color: var(--accent);
134 color: var(--accent);
135 font-weight: 600;
136}
137.btn.danger {
138 background: rgba(224, 90, 90, 0.12);
139 border-color: rgba(224, 90, 90, 0.55);
140 color: var(--loss);
141}
142.btn.ghost { background: transparent; }
143.btn.small { padding: 5px 9px; font-size: 12px; }
144.btn.wide { width: 100%; }
145.btn.big { padding: 13px 18px; font-size: 15px; }
146
147.input {
148 width: 100%;
149 background: var(--bg);
150 border: 1px solid var(--line);
151 border-radius: var(--radius-md);
152 padding: 10px 12px;
153 outline: none;
154}
155.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 184, 201, 0.18); }
156.field { display: grid; gap: 6px; }
157.row { display: flex; align-items: center; gap: 10px; }
158.row.wrap { flex-wrap: wrap; }
159.hr { height: 1px; background: var(--line); border: 0; margin: 18px 0; }
160
161/* Auth */
162.auth-wrap { display: grid; place-items: center; min-height: 52vh; }
163.auth-card {
164 width: min(430px, 100%);
165 background: var(--surface);
166 border: 1px solid var(--line);
167 border-radius: var(--radius-lg);
168 padding: 26px;
169 box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
170}
171.auth-tabs { display: flex; gap: 4px; margin-bottom: 20px; background: var(--bg); padding: 4px; border-radius: var(--radius-md); }
172.auth-tab { flex: 1; border: 0; background: transparent; color: var(--dim); padding: 8px; border-radius: var(--radius-sm); }
173.auth-tab.active { background: var(--raised); color: var(--text); }
174.auth-card form { display: grid; gap: 14px; }
175.auth-foot { margin-top: 16px; font-size: 12px; color: var(--faint); }
176
177/* Lobby */
178.lobby-head { display: flex; align-items: end; gap: 18px; margin-bottom: 22px; flex-wrap: wrap; }
179.lobby-head .spacer { flex: 1; }
180.game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
181.game-card {
182 background: var(--surface);
183 border: 1px solid var(--line);
184 border-radius: var(--radius-lg);
185 padding: 18px;
186 display: flex;
187 flex-direction: column;
188 gap: 12px;
189 min-height: 178px;
190 position: relative;
191 overflow: hidden;
192}
193.game-card::before {
194 content: "";
195 position: absolute;
196 inset: 0 auto 0 0;
197 width: 3px;
198 background: var(--accent);
199 opacity: 0.7;
200}
201.game-card.crash::before { background: var(--gold); }
202.game-card.roulette::before { background: var(--felt-line); }
203.game-card.blackjack::before { background: var(--loss); }
204.game-card h3 { font-size: 17px; }
205.game-card p { margin: 0; color: var(--dim); font-size: 13px; flex: 1; }
206.game-card .row { justify-content: space-between; }
207.bet-input { width: 92px; }
208
209/* Game shell */
210.game-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
211.game-head .spacer { flex: 1; }
212.game-body {
213 display: grid;
214 grid-template-columns: minmax(0, 1fr) 320px;
215 gap: 16px;
216 align-items: start;
217}
218.board {
219 background: var(--surface);
220 border: 1px solid var(--line);
221 border-radius: var(--radius-lg);
222 padding: 18px;
223 min-height: 560px;
224 overflow: hidden;
225}
226.sidebar {
227 background: var(--surface);
228 border: 1px solid var(--line);
229 border-radius: var(--radius-lg);
230 padding: 18px;
231 display: flex;
232 flex-direction: column;
233 gap: 14px;
234 position: sticky;
235 top: 84px;
236}
237.sidebar .actions { margin-top: auto; display: grid; gap: 8px; }
238.sidebar .stat { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
239.sidebar .stat strong { font-family: var(--mono); font-weight: 600; }
240.note { color: var(--dim); font-size: 12px; }
241.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
242.chip-btn {
243 font-family: var(--mono);
244 font-size: 12px;
245 border-radius: 999px;
246 border: 1px solid var(--line);
247 background: var(--raised);
248 padding: 5px 10px;
249}
250.chip-btn.active { border-color: var(--gold); color: var(--gold); background: rgba(224, 182, 74, 0.1); }
251
252/* Slots */
253.reels {
254 display: grid;
255 grid-template-columns: repeat(5, minmax(60px, 1fr));
256 gap: 10px;
257 max-width: 760px;
258 margin: 24px auto 0;
259}
260.reel { display: grid; gap: 8px; }
261.tile {
262 aspect-ratio: 1 / 0.92;
263 display: grid;
264 place-items: center;
265 border-radius: var(--radius-md);
266 border: 1px solid var(--line);
267 background: var(--bg);
268 font-family: var(--mono);
269 font-size: clamp(26px, 5vw, 54px);
270 font-weight: 700;
271 color: var(--dim);
272 transition: color 90ms ease, border-color 90ms ease, background 90ms ease;
273}
274.tile.sym-C { color: #c8d1da; }
275.tile.sym-L { color: #7cc4d4; }
276.tile.sym-O { color: #d8c27a; }
277.tile.sym-G { color: #83d09a; }
278.tile.sym-D { color: #d48d8d; }
279.tile.sym-S { color: #e0b64a; }
280.tile.win { border-color: var(--win); background: rgba(78, 194, 123, 0.12); color: var(--win); }
281.tile.spinning { color: var(--faint); }
282.win-lines { text-align: center; margin-top: 18px; color: var(--dim); font-size: 12px; min-height: 18px; }
283
284/* Cards */
285.hand { margin: 16px 0; }
286.hand .label { margin-bottom: 8px; }
287.cards { display: flex; gap: 8px; flex-wrap: wrap; min-height: 116px; }
288.card {
289 width: 76px;
290 height: 108px;
291 border-radius: 9px;
292 background: #f2f5f7;
293 color: #121820;
294 border: 1px solid #cfd8df;
295 padding: 8px;
296 display: flex;
297 flex-direction: column;
298 justify-content: space-between;
299 font-family: var(--mono);
300 font-weight: 700;
301 box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
302}
303.card.red { color: #a51f2d; }
304.card.back { background: repeating-linear-gradient(135deg, #1d2731, #1d2731 6px, #26333f 6px, #26333f 12px); border-color: #33404d; }
305.card .rank { font-size: 22px; line-height: 1; }
306.card .suit { font-size: 22px; align-self: flex-end; }
307.card.back .rank, .card.back .suit { color: var(--faint); }
308.total { color: var(--dim); font-family: var(--mono); font-size: 12px; }
309.total strong { color: var(--text); font-size: 15px; }
310.outcome { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); }
311
312/* Crash */
313.crash-wrap { position: relative; }
314.crash-number {
315 font-family: var(--mono);
316 font-size: clamp(44px, 9vw, 86px);
317 line-height: 1;
318 letter-spacing: -0.04em;
319}
320.crash-number.running { color: var(--gold); }
321.crash-number.crashed { color: var(--loss); }
322.crash-number.cashed { color: var(--win); }
323.crash-meta { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px; color: var(--faint); font-size: 11px; font-family: var(--mono); }
324.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; }
325.verify.ok { border-color: rgba(78, 194, 123, 0.4); color: var(--win); }
326.verify.bad { border-color: rgba(224, 90, 90, 0.5); color: var(--loss); }
327
328/* Roulette */
329.roulette-wrap { position: relative; }
330canvas { display: block; width: 100%; }
331.roulette-canvas { height: 540px; }
332.crash-canvas { height: 360px; }
333.wheel-result {
334 position: absolute;
335 top: 16px;
336 right: 16px;
337 padding: 8px 12px;
338 border-radius: var(--radius-md);
339 border: 1px solid var(--line);
340 background: rgba(10, 14, 18, 0.86);
341 font-family: var(--mono);
342}
343.wheel-result .num { font-size: 26px; font-weight: 700; }
344.wheel-result.red .num { color: #f06a74; }
345.wheel-result.black .num { color: #dfe8ef; }
346.wheel-result.green .num { color: #66d49a; }
347
348/* History */
349.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-lg); }
350table { width: 100%; border-collapse: collapse; background: var(--surface); }
351th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: 13px; white-space: nowrap; }
352th { color: var(--dim); font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; }
353tr:last-child td { border-bottom: 0; }
354td.num { font-family: var(--mono); }
355td.pos { color: var(--win); }
356td.neg { color: var(--loss); }
357
358/* Toasts and overlays */
359#toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; width: min(380px, calc(100vw - 36px)); }
360.toast {
361 background: var(--surface);
362 border: 1px solid var(--line);
363 border-left: 3px solid var(--accent);
364 border-radius: var(--radius-md);
365 padding: 10px 12px;
366 font-size: 13px;
367 box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
368 animation: toast-in 160ms ease-out;
369}
370.toast.good { border-left-color: var(--win); }
371.toast.bad { border-left-color: var(--loss); }
372@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } }
373.overlay {
374 position: fixed;
375 inset: 0;
376 background: rgba(5, 8, 11, 0.78);
377 display: grid;
378 place-items: center;
379 z-index: 60;
380 padding: 24px;
381}
382.modal {
383 width: min(440px, 100%);
384 background: var(--surface);
385 border: 1px solid var(--line);
386 border-radius: var(--radius-lg);
387 padding: 24px;
388 text-align: center;
389}
390.modal p { color: var(--dim); }
391
392@media (max-width: 900px) {
393 .game-body { grid-template-columns: 1fr; }
394 .sidebar { position: static; }
395 .tagline { display: none; }
396}
397@media (max-width: 620px) {
398 main { padding: 18px 14px 54px; }
399 .topbar { padding: 0 14px; gap: 10px; }
400 .session .who { display: none; }
401 .balance { font-size: 14px; }
402 .reels { gap: 6px; }
403 .tile { font-size: clamp(18px, 7vw, 34px); }
404 .card { width: 58px; height: 84px; }
405 .crash-canvas { height: 260px; }
406 .roulette-canvas { height: 400px; }
407}
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 @@
1import fs from "node:fs";
2import { webcrypto } from "node:crypto";
3import { JSDOM, VirtualConsole } from "jsdom";
4
5const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8")
6 .replace('<script src="app.js" defer></script>', "");
7const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8");
8
9const failures = [];
10const virtualConsole = new VirtualConsole();
11virtualConsole.on("jsdomError", (error) => failures.push(String(error)));
12
13const dom = new JSDOM(html, {
14 url: "http://127.0.0.1:8899/play/",
15 runScripts: "outside-only",
16 pretendToBeVisual: true,
17 virtualConsole,
18});
19const { window } = dom;
20
21Object.defineProperty(window, "crypto", { value: webcrypto });
22window.devicePixelRatio = 1;
23window.confirm = () => true;
24
25class MockWebSocket {
26 static OPEN = 1;
27 static instances = [];
28 readyState = MockWebSocket.OPEN;
29 sent = [];
30 listeners = new Map();
31
32 constructor(url) {
33 this.url = url;
34 MockWebSocket.instances.push(this);
35 queueMicrotask(() => this.emit("open", {}));
36 }
37
38 addEventListener(type, listener) {
39 const list = this.listeners.get(type) ?? [];
40 list.push(listener);
41 this.listeners.set(type, list);
42 }
43
44 send(raw) {
45 this.sent.push(JSON.parse(raw));
46 }
47
48 close() {
49 this.emit("close", {});
50 }
51
52 emit(type, event) {
53 for (const listener of this.listeners.get(type) ?? []) listener(event);
54 }
55
56 message(message) {
57 this.emit("message", { data: JSON.stringify(message) });
58 }
59}
60window.WebSocket = MockWebSocket;
61window.ResizeObserver = class {
62 observe() {}
63 disconnect() {}
64};
65window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0);
66window.cancelAnimationFrame = (handle) => window.clearTimeout(handle);
67
68const context = new Proxy(
69 {},
70 {
71 get(target, key) {
72 if (key === "measureText") return () => ({ width: 10 });
73 if (key in target) return target[key];
74 return () => undefined;
75 },
76 set(target, key, value) {
77 target[key] = value;
78 return true;
79 },
80 },
81);
82window.HTMLCanvasElement.prototype.getContext = function getContext() {
83 return context;
84};
85
86window.eval(bundle);
87
88const document = window.document;
89const byId = (id) => document.getElementById(id);
90const assert = (condition, message) => {
91 if (!condition) failures.push(message);
92};
93
94const socket = MockWebSocket.instances[0];
95assert(socket, "the app opened a WebSocket");
96assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`);
97
98const hello = {
99 type: "hello",
100 version: "5.0.0",
101 grace_seconds: 60,
102 time: Date.now(),
103};
104const lobby = {
105 type: "view",
106 view: {
107 screen: "lobby",
108 connected: true,
109 user: null,
110 account: null,
111 balance: null,
112 games: [
113 ["blackjack", "Blackjack"],
114 ["crash", "Crash"],
115 ["roulette", "Roulette"],
116 ["slots", "Slots"],
117 ],
118 login_error: null,
119 },
120};
121socket.message(hello);
122socket.message(lobby);
123
124assert(byId("auth-screen").hidden === false, "auth is shown before login");
125assert(byId("session-bar").hidden === true, "no session bar before login");
126
127byId("auth-user").value = "alice";
128byId("auth-pass").value = "secret";
129byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
130
131await new Promise((resolve) => setTimeout(resolve, 10));
132const login = socket.sent.find((message) => message.command?.Login);
133assert(login, "the login form sends a Login command");
134assert(login.command.Login.user === "alice", "the username is sent");
135assert(
136 /^[0-9a-f]{64}$/.test(login.command.Login.credential),
137 "the password is sent as a SHA-256 hex digest",
138);
139
140socket.message({
141 type: "view",
142 view: {
143 screen: "lobby",
144 connected: true,
145 user: "alice",
146 account: "1",
147 balance: null,
148 games: lobby.view.games,
149 login_error: null,
150 },
151});
152assert(
153 socket.sent.some((message) => message.command === "Info"),
154 "the app asks for the balance after login",
155);
156socket.message({
157 type: "view",
158 view: {
159 screen: "lobby",
160 connected: true,
161 user: "alice",
162 account: "1",
163 balance: 1000,
164 games: lobby.view.games,
165 login_error: null,
166 },
167});
168assert(byId("auth-screen").hidden === true, "auth is hidden after login");
169assert(byId("lobby-screen").hidden === false, "the lobby is shown after login");
170assert(byId("account-name").textContent === "alice", "the account name is shown");
171assert(byId("account-balance").textContent.includes("1"), "the balance is shown");
172assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render");
173socket.message({
174 type: "view",
175 view: {
176 screen: "slots",
177 bet: 10,
178 balance: 1000,
179 reels: "CLOGDSCLOGDSLLO".split(""),
180 win_cells: [0, 1, 2],
181 win_lines: [1],
182 win_type: "win",
183 winnings: 20,
184 phase: "result",
185 error: null,
186 },
187});
188assert(byId("game-screen").hidden === false, "the game screen is shown");
189assert(document.querySelectorAll(".reel").length === 5, "five reels render");
190assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render");
191assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action");
192
193socket.message({
194 type: "view",
195 view: {
196 screen: "roulette",
197 bet_amount: 10,
198 balance: 1000,
199 chip: 25,
200 bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] },
201 cursor: { Lattice: { x: 1, y: 5 } },
202 phase: "result",
203 number: 5,
204 color: "red",
205 winnings: 900,
206 error: null,
207 },
208});
209assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders");
210assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders");
211assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders");
212
213socket.message({
214 type: "view",
215 view: {
216 screen: "blackjack",
217 status: "player",
218 balance: 990,
219 bet: 10,
220 hand: {
221 session_id: "bj1",
222 phase: "player",
223 player: ["10S", "6H"],
224 dealer: ["9D", "?"],
225 player_total: 16,
226 dealer_total: 9,
227 bet: 10,
228 base_bet: 10,
229 balance: 990,
230 actions: ["hit", "stand", "double"],
231 result: "playing",
232 winnings: 0,
233 },
234 error: null,
235 },
236});
237assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render");
238assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render");
239assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase");
240assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders");
241
242socket.message({
243 type: "view",
244 view: {
245 screen: "crash",
246 status: "running",
247 balance: 990,
248 bet: 10,
249 multiplier: 1.2,
250 potential: 12,
251 crash_point: null,
252 payout: 0,
253 started_at: Date.now(),
254 commit: "ab".repeat(32),
255 seed: null,
256 error: null,
257 },
258});
259assert(document.querySelector(".crash-canvas"), "the crash canvas renders");
260assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders");
261assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running");
262assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment");
263
264socket.message({
265 type: "view",
266 view: {
267 screen: "history",
268 records: [
269 {
270 game: "slots",
271 bet: 10,
272 winnings: 20,
273 balance_after: 1010,
274 at: "2026-11-08 03:00:00",
275 details: { win_type: "win" },
276 },
277 ],
278 },
279});
280assert(byId("history-screen").hidden === false, "the history screen renders");
281assert(document.querySelector("table tbody tr"), "history rows render");
282
283socket.message({ type: "replaced" });
284assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab");
285
286await new Promise((resolve) => setTimeout(resolve, 30));
287dom.window.close();
288
289if (failures.length > 0) {
290 console.error("web smoke test failed:");
291 for (const failure of failures) console.error(` - ${failure}`);
292 process.exit(1);
293}
294console.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 @@
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ESNext",
5 "moduleResolution": "Bundler",
6 "strict": true,
7 "noEmit": true,
8 "lib": ["ES2020", "DOM", "DOM.Iterable"],
9 "skipLibCheck": true,
10 "forceConsistentCasingInFileNames": true
11 },
12 "include": ["src"]
13}