From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- web/play/src/slots.ts | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 web/play/src/slots.ts (limited to 'web/play/src/slots.ts') diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts new file mode 100644 index 0000000..dba1e25 --- /dev/null +++ b/web/play/src/slots.ts @@ -0,0 +1,144 @@ +import type { View } from "./types"; +import { clear, money, node } from "./dom"; + +const REELS = 5; +const ROWS = 3; +const SYMBOLS = "CLOGDS"; +const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; + +let spinUntil = 0; +let animation: number | undefined; +let latest: View | null = null; +let latestBoard: HTMLElement | null = null; + +export function requestSlotsSpin(): void { + spinUntil = Date.now() + 900; +} + +function spinning(view: View): boolean { + return view.phase === "spinning" || Date.now() < spinUntil; +} + +export function renderSlots( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + latest = view; + latestBoard = board; + if (animation !== undefined) { + window.clearInterval(animation); + animation = undefined; + } + + const active = spinning(view); + clear(board); + clear(side); + + const title = node("h2", undefined, "Slots"); + const note = node("p", "note", "Match three or more symbols from the left. Five paylines."); + board.append(title, note); + + const reels = node("div", "reels"); + const tiles: HTMLElement[] = []; + const flat = String(view.reels ?? ""); + for (let column = 0; column < REELS; column += 1) { + const reel = node("div", "reel"); + for (let row = 0; row < ROWS; row += 1) { + const index = column * ROWS + row; + const symbol = active + ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length] + : flat.length === REELS * ROWS + ? flat[index] + : flat.length === REELS + ? flat[column] + : SYMBOLS[(index * 5 + column) % SYMBOLS.length]; + const tile = node("div", "tile", symbol); + tile.classList.add(`sym-${symbol}`); + if (active) tile.classList.add("spinning"); + if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win"); + tiles.push(tile); + reel.append(tile); + } + reels.append(reel); + } + board.append(reels); + + const lines = node("div", "win-lines"); + if (!active && view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + if (winnings > 0) { + const names = (view.win_lines ?? []) + .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) + .join(" · "); + lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; + lines.classList.add("win-text"); + } else { + lines.textContent = "No win — spin again."; + } + } else if (active) { + lines.textContent = "Spinning…"; + } else { + lines.textContent = "Pick a bet and spin."; + } + board.append(lines); + + if (active) { + let tick = 0; + animation = window.setInterval(() => { + tick += 1; + for (let index = 0; index < tiles.length; index += 1) { + const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length]; + tiles[index].textContent = symbol; + tiles[index].className = `tile spinning sym-${symbol}`; + } + if (Date.now() >= spinUntil && latest && latestBoard) { + window.clearInterval(animation); + animation = undefined; + renderSlots(latestBoard, side, latest, send); + } + }, 70); + } + + // Sidebar + side.append(node("div", "label", "Slots")); + side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); + + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10"); + const bet = node("strong", "mono", money(Number(view.bet ?? 0))); + const plus = node("button", "btn small", "+10"); + minus.disabled = active; + plus.disabled = active; + minus.addEventListener("click", () => send({ AdjustBet: -10 })); + plus.addEventListener("click", () => send({ AdjustBet: 10 })); + betRow.append(minus, bet, plus); + side.append(node("div", "label", "Bet"), betRow); + + if (view.error) { + side.append(node("p", "error-text", String(view.error))); + } + + const status = node("p", "note"); + if (active) status.textContent = "Spinning…"; + else if (view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; + if (winnings > 0) status.classList.add("win-text"); + } else status.textContent = "Ready when you are."; + side.append(status); + + const actions = node("div", "actions"); + const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN"); + spin.disabled = active; + spin.addEventListener("click", () => { + requestSlotsSpin(); + send("Spin"); + }); + const leave = node("button", "btn ghost wide", "Leave machine"); + leave.disabled = active; + leave.addEventListener("click", () => send("Leave")); + actions.append(spin, leave); + side.append(actions); +} -- cgit v1.2.3