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/dom.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 web/play/src/dom.ts (limited to 'web/play/src/dom.ts') diff --git a/web/play/src/dom.ts b/web/play/src/dom.ts new file mode 100644 index 0000000..d8ed212 --- /dev/null +++ b/web/play/src/dom.ts @@ -0,0 +1,104 @@ +export function byId(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error(`missing #${id}`); + return node as T; +} + +export function node( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +export function clear(element: HTMLElement): void { + element.replaceChildren(); +} + +export function groupDigits(amount: number): string { + const negative = amount < 0; + const digits = String(Math.abs(Math.trunc(amount))); + let grouped = ""; + for (let index = 0; index < digits.length; index += 1) { + if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009"; + grouped += digits[index]; + } + return negative ? `-${grouped}` : grouped; +} + +export function money(amount: number | null | undefined): string { + if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr"; + return `${groupDigits(amount)} Kr`; +} + +export function signedMoney(amount: number): string { + return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`; +} + +export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void { + const root = document.getElementById("toasts"); + if (!root || !text) return; + const item = node("div", `toast ${tone}`, text); + root.append(item); + window.setTimeout(() => { + item.style.opacity = "0"; + item.style.transition = "opacity 220ms ease"; + window.setTimeout(() => item.remove(), 240); + }, 4200); + while (root.children.length > 4) root.firstElementChild?.remove(); +} + +export function toneFor(text: string): "info" | "good" | "bad" { + const lowered = text.toLowerCase(); + if ( + text.startsWith("ERROR") || + lowered.includes("failed") || + lowered.includes("lost") || + lowered.includes("invalid") || + lowered.includes("forfeit") + ) { + return "bad"; + } + if ( + text.startsWith("Logged in") || + text.startsWith("Account created") || + lowered.includes("won") || + lowered.includes("cashed") + ) { + return "good"; + } + return "info"; +} + +export async function sha256Hex(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export function shortHash(hash: string, keep = 10): string { + if (hash.length <= keep * 2) return hash; + return `${hash.slice(0, keep)}…${hash.slice(-keep)}`; +} + +export function formatTime(raw: string): string { + // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC. + const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z"); + if (Number.isNaN(parsed)) return raw; + return new Date(parsed).toLocaleString(); +} + +export function cardFace(card: string): { rank: string; suit: string; red: boolean } { + if (card === "?" || !card) return { rank: "?", suit: "", red: false }; + const suit = card.slice(-1); + const rank = card.slice(0, -1); + const glyph = + suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit; + return { rank, suit: glyph, red: suit === "D" || suit === "H" }; +} -- cgit v1.2.3