summaryrefslogtreecommitdiffstats
path: root/web/play/test
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /web/play/test
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz
zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip
v5.0.0: protocol v5 and the browser frontend
Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO.
Diffstat (limited to 'web/play/test')
-rw-r--r--web/play/test/smoke.mjs294
1 files changed, 294 insertions, 0 deletions
diff --git a/web/play/test/smoke.mjs b/web/play/test/smoke.mjs
new file mode 100644
index 0000000..c1274bc
--- /dev/null
+++ b/web/play/test/smoke.mjs
@@ -0,0 +1,294 @@
1import fs from "node:fs";
2import { webcrypto } from "node:crypto";
3import { JSDOM, VirtualConsole } from "jsdom";
4
5const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8")
6 .replace('<script src="app.js" defer></script>', "");
7const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8");
8
9const failures = [];
10const virtualConsole = new VirtualConsole();
11virtualConsole.on("jsdomError", (error) => failures.push(String(error)));
12
13const dom = new JSDOM(html, {
14 url: "http://127.0.0.1:8899/play/",
15 runScripts: "outside-only",
16 pretendToBeVisual: true,
17 virtualConsole,
18});
19const { window } = dom;
20
21Object.defineProperty(window, "crypto", { value: webcrypto });
22window.devicePixelRatio = 1;
23window.confirm = () => true;
24
25class MockWebSocket {
26 static OPEN = 1;
27 static instances = [];
28 readyState = MockWebSocket.OPEN;
29 sent = [];
30 listeners = new Map();
31
32 constructor(url) {
33 this.url = url;
34 MockWebSocket.instances.push(this);
35 queueMicrotask(() => this.emit("open", {}));
36 }
37
38 addEventListener(type, listener) {
39 const list = this.listeners.get(type) ?? [];
40 list.push(listener);
41 this.listeners.set(type, list);
42 }
43
44 send(raw) {
45 this.sent.push(JSON.parse(raw));
46 }
47
48 close() {
49 this.emit("close", {});
50 }
51
52 emit(type, event) {
53 for (const listener of this.listeners.get(type) ?? []) listener(event);
54 }
55
56 message(message) {
57 this.emit("message", { data: JSON.stringify(message) });
58 }
59}
60window.WebSocket = MockWebSocket;
61window.ResizeObserver = class {
62 observe() {}
63 disconnect() {}
64};
65window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0);
66window.cancelAnimationFrame = (handle) => window.clearTimeout(handle);
67
68const context = new Proxy(
69 {},
70 {
71 get(target, key) {
72 if (key === "measureText") return () => ({ width: 10 });
73 if (key in target) return target[key];
74 return () => undefined;
75 },
76 set(target, key, value) {
77 target[key] = value;
78 return true;
79 },
80 },
81);
82window.HTMLCanvasElement.prototype.getContext = function getContext() {
83 return context;
84};
85
86window.eval(bundle);
87
88const document = window.document;
89const byId = (id) => document.getElementById(id);
90const assert = (condition, message) => {
91 if (!condition) failures.push(message);
92};
93
94const socket = MockWebSocket.instances[0];
95assert(socket, "the app opened a WebSocket");
96assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`);
97
98const hello = {
99 type: "hello",
100 version: "5.0.0",
101 grace_seconds: 60,
102 time: Date.now(),
103};
104const lobby = {
105 type: "view",
106 view: {
107 screen: "lobby",
108 connected: true,
109 user: null,
110 account: null,
111 balance: null,
112 games: [
113 ["blackjack", "Blackjack"],
114 ["crash", "Crash"],
115 ["roulette", "Roulette"],
116 ["slots", "Slots"],
117 ],
118 login_error: null,
119 },
120};
121socket.message(hello);
122socket.message(lobby);
123
124assert(byId("auth-screen").hidden === false, "auth is shown before login");
125assert(byId("session-bar").hidden === true, "no session bar before login");
126
127byId("auth-user").value = "alice";
128byId("auth-pass").value = "secret";
129byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
130
131await new Promise((resolve) => setTimeout(resolve, 10));
132const login = socket.sent.find((message) => message.command?.Login);
133assert(login, "the login form sends a Login command");
134assert(login.command.Login.user === "alice", "the username is sent");
135assert(
136 /^[0-9a-f]{64}$/.test(login.command.Login.credential),
137 "the password is sent as a SHA-256 hex digest",
138);
139
140socket.message({
141 type: "view",
142 view: {
143 screen: "lobby",
144 connected: true,
145 user: "alice",
146 account: "1",
147 balance: null,
148 games: lobby.view.games,
149 login_error: null,
150 },
151});
152assert(
153 socket.sent.some((message) => message.command === "Info"),
154 "the app asks for the balance after login",
155);
156socket.message({
157 type: "view",
158 view: {
159 screen: "lobby",
160 connected: true,
161 user: "alice",
162 account: "1",
163 balance: 1000,
164 games: lobby.view.games,
165 login_error: null,
166 },
167});
168assert(byId("auth-screen").hidden === true, "auth is hidden after login");
169assert(byId("lobby-screen").hidden === false, "the lobby is shown after login");
170assert(byId("account-name").textContent === "alice", "the account name is shown");
171assert(byId("account-balance").textContent.includes("1"), "the balance is shown");
172assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render");
173socket.message({
174 type: "view",
175 view: {
176 screen: "slots",
177 bet: 10,
178 balance: 1000,
179 reels: "CLOGDSCLOGDSLLO".split(""),
180 win_cells: [0, 1, 2],
181 win_lines: [1],
182 win_type: "win",
183 winnings: 20,
184 phase: "result",
185 error: null,
186 },
187});
188assert(byId("game-screen").hidden === false, "the game screen is shown");
189assert(document.querySelectorAll(".reel").length === 5, "five reels render");
190assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render");
191assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action");
192
193socket.message({
194 type: "view",
195 view: {
196 screen: "roulette",
197 bet_amount: 10,
198 balance: 1000,
199 chip: 25,
200 bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] },
201 cursor: { Lattice: { x: 1, y: 5 } },
202 phase: "result",
203 number: 5,
204 color: "red",
205 winnings: 900,
206 error: null,
207 },
208});
209assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders");
210assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders");
211assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders");
212
213socket.message({
214 type: "view",
215 view: {
216 screen: "blackjack",
217 status: "player",
218 balance: 990,
219 bet: 10,
220 hand: {
221 session_id: "bj1",
222 phase: "player",
223 player: ["10S", "6H"],
224 dealer: ["9D", "?"],
225 player_total: 16,
226 dealer_total: 9,
227 bet: 10,
228 base_bet: 10,
229 balance: 990,
230 actions: ["hit", "stand", "double"],
231 result: "playing",
232 winnings: 0,
233 },
234 error: null,
235 },
236});
237assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render");
238assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render");
239assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase");
240assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders");
241
242socket.message({
243 type: "view",
244 view: {
245 screen: "crash",
246 status: "running",
247 balance: 990,
248 bet: 10,
249 multiplier: 1.2,
250 potential: 12,
251 crash_point: null,
252 payout: 0,
253 started_at: Date.now(),
254 commit: "ab".repeat(32),
255 seed: null,
256 error: null,
257 },
258});
259assert(document.querySelector(".crash-canvas"), "the crash canvas renders");
260assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders");
261assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running");
262assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment");
263
264socket.message({
265 type: "view",
266 view: {
267 screen: "history",
268 records: [
269 {
270 game: "slots",
271 bet: 10,
272 winnings: 20,
273 balance_after: 1010,
274 at: "2026-11-08 03:00:00",
275 details: { win_type: "win" },
276 },
277 ],
278 },
279});
280assert(byId("history-screen").hidden === false, "the history screen renders");
281assert(document.querySelector("table tbody tr"), "history rows render");
282
283socket.message({ type: "replaced" });
284assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab");
285
286await new Promise((resolve) => setTimeout(resolve, 30));
287dom.window.close();
288
289if (failures.length > 0) {
290 console.error("web smoke test failed:");
291 for (const failure of failures) console.error(` - ${failure}`);
292 process.exit(1);
293}
294console.log("web smoke test passed");