summaryrefslogtreecommitdiffstats
path: root/web/play/src/main.ts
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--web/play/src/main.ts323
1 files changed, 323 insertions, 0 deletions
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 @@
1import { byId, money, node, sha256Hex, toast, toneFor } from "./dom";
2import { Gateway } from "./net";
3import { renderBlackjack } from "./blackjack";
4import { renderCrash } from "./crash";
5import { renderHistory } from "./history";
6import { renderRoulette } from "./roulette";
7import { renderSlots } from "./slots";
8import type { View } from "./types";
9
10const authScreen = byId("auth-screen");
11const lobbyScreen = byId("lobby-screen");
12const gameScreen = byId("game-screen");
13const historyScreen = byId("history-screen");
14const sessionBar = byId("session-bar");
15const accountName = byId("account-name");
16const accountBalance = byId("account-balance");
17const gameGrid = byId("game-grid");
18const gameBoard = byId("game-board");
19const gameSide = byId("game-side");
20const gameTitle = byId("game-title");
21const gameBadge = byId("game-badge");
22const historyBody = byId("history-body");
23const authForm = byId<HTMLFormElement>("auth-form");
24const authUser = byId<HTMLInputElement>("auth-user");
25const authPass = byId<HTMLInputElement>("auth-pass");
26const authError = byId("auth-error");
27const authSubmit = byId<HTMLButtonElement>("auth-submit");
28const tabLogin = byId<HTMLButtonElement>("tab-login");
29const tabRegister = byId<HTMLButtonElement>("tab-register");
30const reconnectOverlay = byId("reconnect-overlay");
31const reconnectMessage = byId("reconnect-message");
32const replacedOverlay = byId("replaced-overlay");
33
34let view: View | null = null;
35let currentUser: string | null = null;
36let lastBalance: number | null = null;
37let authMode: "login" | "register" = "login";
38let infoFor: string | null = null;
39let serverOffset = 0;
40let reconnectAttempts = 0;
41const bets = new Map<string, number>([
42 ["slots", 10],
43 ["roulette", 10],
44 ["blackjack", 10],
45 ["crash", 10],
46]);
47
48const titles: Record<string, string> = {
49 slots: "Slots",
50 roulette: "Roulette",
51 blackjack: "Blackjack",
52 crash: "Crash",
53};
54
55const 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
62const basePath = location.pathname.includes("/play/")
63 ? location.pathname.slice(0, location.pathname.indexOf("/play/"))
64 : "";
65const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`;
66
67const 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
142function send(command: unknown): void {
143 gateway.send(command);
144}
145
146function serverNow(): number {
147 return Date.now() + serverOffset;
148}
149
150function 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
159function 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
166function setBalance(balance: number | null | undefined): void {
167 accountBalance.textContent = money(balance ?? null);
168}
169
170function 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
217function 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
225function 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 -------------------------------------------------------
266tabLogin.addEventListener("click", () => setAuthMode("login"));
267tabRegister.addEventListener("click", () => setAuthMode("register"));
268
269authForm.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 ----------------------------------------------------
295byId("logout").addEventListener("click", () => send("Logout"));
296byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } }));
297byId("refresh-lobby").addEventListener("click", () => send("Info"));
298byId("history-back").addEventListener("click", () => send("ShowLobby"));
299byId("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});
307byId("reload-replaced").addEventListener("click", () => location.reload());
308byId("reload-reconnect").addEventListener("click", () => location.reload());
309
310window.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.
319window.setInterval(() => {
320 if (currentUser) gateway.ping();
321}, 25_000);
322
323gateway.connect();