diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
| commit | 058432130628d00764663ecf819a9adc6793768d (patch) | |
| tree | d3f49f111fbfaa48647cca2600adcf9017af2d30 /web/play/src | |
| parent | web: serve latest.php through PHP and refuse stray PHP files (diff) | |
| download | zagan-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 '')
| -rw-r--r-- | web/play/src/blackjack.ts | 152 | ||||
| -rw-r--r-- | web/play/src/crash.ts | 365 | ||||
| -rw-r--r-- | web/play/src/dom.ts | 104 | ||||
| -rw-r--r-- | web/play/src/history.ts | 60 | ||||
| -rw-r--r-- | web/play/src/main.ts | 323 | ||||
| -rw-r--r-- | web/play/src/net.ts | 120 | ||||
| -rw-r--r-- | web/play/src/roulette.ts | 474 | ||||
| -rw-r--r-- | web/play/src/slots.ts | 144 | ||||
| -rw-r--r-- | web/play/src/types.ts | 27 |
9 files changed, 1769 insertions, 0 deletions
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 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { cardFace, clear, money, node } from "./dom"; | ||
| 3 | |||
| 4 | function 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 | |||
| 17 | function 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 | |||
| 28 | function 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 | |||
| 51 | export 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 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node, shortHash } from "./dom"; | ||
| 3 | |||
| 4 | const GROWTH = Math.LN2 / 5; | ||
| 5 | |||
| 6 | type Clock = { now: () => number }; | ||
| 7 | |||
| 8 | type 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 | |||
| 27 | let parts: Parts | null = null; | ||
| 28 | let current: View | null = null; | ||
| 29 | let clock: Clock | null = null; | ||
| 30 | let sendCommand: ((command: unknown) => void) | null = null; | ||
| 31 | let frameHandle: number | undefined; | ||
| 32 | let verifyToken = 0; | ||
| 33 | let lastStatus = ""; | ||
| 34 | |||
| 35 | function 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 | |||
| 121 | export 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 | |||
| 226 | function 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 | |||
| 237 | function 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 | |||
| 253 | function 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 | |||
| 342 | async 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 @@ | |||
| 1 | export 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 | |||
| 7 | export 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 | |||
| 18 | export function clear(element: HTMLElement): void { | ||
| 19 | element.replaceChildren(); | ||
| 20 | } | ||
| 21 | |||
| 22 | export 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 | |||
| 33 | export 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 | |||
| 38 | export function signedMoney(amount: number): string { | ||
| 39 | return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`; | ||
| 40 | } | ||
| 41 | |||
| 42 | export 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 | |||
| 55 | export 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 | |||
| 77 | export 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 | |||
| 85 | export 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 | |||
| 90 | export 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 | |||
| 97 | export 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 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, formatTime, money, node, signedMoney } from "./dom"; | ||
| 3 | |||
| 4 | function 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 | |||
| 23 | export 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 @@ | |||
| 1 | import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; | ||
| 2 | import { Gateway } from "./net"; | ||
| 3 | import { renderBlackjack } from "./blackjack"; | ||
| 4 | import { renderCrash } from "./crash"; | ||
| 5 | import { renderHistory } from "./history"; | ||
| 6 | import { renderRoulette } from "./roulette"; | ||
| 7 | import { renderSlots } from "./slots"; | ||
| 8 | import type { View } from "./types"; | ||
| 9 | |||
| 10 | const authScreen = byId("auth-screen"); | ||
| 11 | const lobbyScreen = byId("lobby-screen"); | ||
| 12 | const gameScreen = byId("game-screen"); | ||
| 13 | const historyScreen = byId("history-screen"); | ||
| 14 | const sessionBar = byId("session-bar"); | ||
| 15 | const accountName = byId("account-name"); | ||
| 16 | const accountBalance = byId("account-balance"); | ||
| 17 | const gameGrid = byId("game-grid"); | ||
| 18 | const gameBoard = byId("game-board"); | ||
| 19 | const gameSide = byId("game-side"); | ||
| 20 | const gameTitle = byId("game-title"); | ||
| 21 | const gameBadge = byId("game-badge"); | ||
| 22 | const historyBody = byId("history-body"); | ||
| 23 | const authForm = byId<HTMLFormElement>("auth-form"); | ||
| 24 | const authUser = byId<HTMLInputElement>("auth-user"); | ||
| 25 | const authPass = byId<HTMLInputElement>("auth-pass"); | ||
| 26 | const authError = byId("auth-error"); | ||
| 27 | const authSubmit = byId<HTMLButtonElement>("auth-submit"); | ||
| 28 | const tabLogin = byId<HTMLButtonElement>("tab-login"); | ||
| 29 | const tabRegister = byId<HTMLButtonElement>("tab-register"); | ||
| 30 | const reconnectOverlay = byId("reconnect-overlay"); | ||
| 31 | const reconnectMessage = byId("reconnect-message"); | ||
| 32 | const replacedOverlay = byId("replaced-overlay"); | ||
| 33 | |||
| 34 | let view: View | null = null; | ||
| 35 | let currentUser: string | null = null; | ||
| 36 | let lastBalance: number | null = null; | ||
| 37 | let authMode: "login" | "register" = "login"; | ||
| 38 | let infoFor: string | null = null; | ||
| 39 | let serverOffset = 0; | ||
| 40 | let reconnectAttempts = 0; | ||
| 41 | const bets = new Map<string, number>([ | ||
| 42 | ["slots", 10], | ||
| 43 | ["roulette", 10], | ||
| 44 | ["blackjack", 10], | ||
| 45 | ["crash", 10], | ||
| 46 | ]); | ||
| 47 | |||
| 48 | const titles: Record<string, string> = { | ||
| 49 | slots: "Slots", | ||
| 50 | roulette: "Roulette", | ||
| 51 | blackjack: "Blackjack", | ||
| 52 | crash: "Crash", | ||
| 53 | }; | ||
| 54 | |||
| 55 | const 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 | |||
| 62 | const basePath = location.pathname.includes("/play/") | ||
| 63 | ? location.pathname.slice(0, location.pathname.indexOf("/play/")) | ||
| 64 | : ""; | ||
| 65 | const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; | ||
| 66 | |||
| 67 | const 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 | |||
| 142 | function send(command: unknown): void { | ||
| 143 | gateway.send(command); | ||
| 144 | } | ||
| 145 | |||
| 146 | function serverNow(): number { | ||
| 147 | return Date.now() + serverOffset; | ||
| 148 | } | ||
| 149 | |||
| 150 | function 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 | |||
| 159 | function 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 | |||
| 166 | function setBalance(balance: number | null | undefined): void { | ||
| 167 | accountBalance.textContent = money(balance ?? null); | ||
| 168 | } | ||
| 169 | |||
| 170 | function 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 | |||
| 217 | function 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 | |||
| 225 | function 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 ------------------------------------------------------- | ||
| 266 | tabLogin.addEventListener("click", () => setAuthMode("login")); | ||
| 267 | tabRegister.addEventListener("click", () => setAuthMode("register")); | ||
| 268 | |||
| 269 | authForm.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 ---------------------------------------------------- | ||
| 295 | byId("logout").addEventListener("click", () => send("Logout")); | ||
| 296 | byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); | ||
| 297 | byId("refresh-lobby").addEventListener("click", () => send("Info")); | ||
| 298 | byId("history-back").addEventListener("click", () => send("ShowLobby")); | ||
| 299 | byId("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 | }); | ||
| 307 | byId("reload-replaced").addEventListener("click", () => location.reload()); | ||
| 308 | byId("reload-reconnect").addEventListener("click", () => location.reload()); | ||
| 309 | |||
| 310 | window.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. | ||
| 319 | window.setInterval(() => { | ||
| 320 | if (currentUser) gateway.ping(); | ||
| 321 | }, 25_000); | ||
| 322 | |||
| 323 | gateway.connect(); | ||
diff --git a/web/play/src/net.ts b/web/play/src/net.ts new file mode 100644 index 0000000..0a963ba --- /dev/null +++ b/web/play/src/net.ts | |||
| @@ -0,0 +1,120 @@ | |||
| 1 | import type { Handlers } from "./types"; | ||
| 2 | |||
| 3 | const 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 | */ | ||
| 12 | export 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 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node } from "./dom"; | ||
| 3 | |||
| 4 | type 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 | |||
| 15 | type Hotspot = { | ||
| 16 | x: number; | ||
| 17 | y: number; | ||
| 18 | radius: number; | ||
| 19 | target: any; | ||
| 20 | rank: number; | ||
| 21 | }; | ||
| 22 | |||
| 23 | type 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 | |||
| 41 | const RED = "#b0313a"; | ||
| 42 | const BLACK = "#151b21"; | ||
| 43 | const ZERO = "#106b45"; | ||
| 44 | const FELT = "#0d5c39"; | ||
| 45 | const FELT_DARK = "#09472c"; | ||
| 46 | const FELT_LINE = "#1f7d52"; | ||
| 47 | const TEXT = "#ecf7f0"; | ||
| 48 | const GOLD = "#e0b64a"; | ||
| 49 | |||
| 50 | function 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 | |||
| 56 | function 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 | |||
| 222 | function 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 | |||
| 240 | function 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 | |||
| 283 | function targetKey(target: any): string { | ||
| 284 | return JSON.stringify(target); | ||
| 285 | } | ||
| 286 | |||
| 287 | function 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 | |||
| 309 | function 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 | |||
| 330 | export 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 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node } from "./dom"; | ||
| 3 | |||
| 4 | const REELS = 5; | ||
| 5 | const ROWS = 3; | ||
| 6 | const SYMBOLS = "CLOGDS"; | ||
| 7 | const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; | ||
| 8 | |||
| 9 | let spinUntil = 0; | ||
| 10 | let animation: number | undefined; | ||
| 11 | let latest: View | null = null; | ||
| 12 | let latestBoard: HTMLElement | null = null; | ||
| 13 | |||
| 14 | export function requestSlotsSpin(): void { | ||
| 15 | spinUntil = Date.now() + 900; | ||
| 16 | } | ||
| 17 | |||
| 18 | function spinning(view: View): boolean { | ||
| 19 | return view.phase === "spinning" || Date.now() < spinUntil; | ||
| 20 | } | ||
| 21 | |||
| 22 | export 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 @@ | |||
| 1 | export type View = { | ||
| 2 | screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash"; | ||
| 3 | [key: string]: any; | ||
| 4 | }; | ||
| 5 | |||
| 6 | export 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 | |||
| 16 | export type Send = (command: unknown) => void; | ||
| 17 | |||
| 18 | export 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 | }; | ||
