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/dom.ts | |
| 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/dom.ts | 104 |
1 files changed, 104 insertions, 0 deletions
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 | } | ||
