summaryrefslogtreecommitdiffstats
path: root/web/play/src/slots.ts
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/src/slots.ts
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/src/slots.ts')
-rw-r--r--web/play/src/slots.ts144
1 files changed, 144 insertions, 0 deletions
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 @@
1import type { View } from "./types";
2import { clear, money, node } from "./dom";
3
4const REELS = 5;
5const ROWS = 3;
6const SYMBOLS = "CLOGDS";
7const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"];
8
9let spinUntil = 0;
10let animation: number | undefined;
11let latest: View | null = null;
12let latestBoard: HTMLElement | null = null;
13
14export function requestSlotsSpin(): void {
15 spinUntil = Date.now() + 900;
16}
17
18function spinning(view: View): boolean {
19 return view.phase === "spinning" || Date.now() < spinUntil;
20}
21
22export function renderSlots(
23 board: HTMLElement,
24 side: HTMLElement,
25 view: View,
26 send: (command: unknown) => void,
27): void {
28 latest = view;
29 latestBoard = board;
30 if (animation !== undefined) {
31 window.clearInterval(animation);
32 animation = undefined;
33 }
34
35 const active = spinning(view);
36 clear(board);
37 clear(side);
38
39 const title = node("h2", undefined, "Slots");
40 const note = node("p", "note", "Match three or more symbols from the left. Five paylines.");
41 board.append(title, note);
42
43 const reels = node("div", "reels");
44 const tiles: HTMLElement[] = [];
45 const flat = String(view.reels ?? "");
46 for (let column = 0; column < REELS; column += 1) {
47 const reel = node("div", "reel");
48 for (let row = 0; row < ROWS; row += 1) {
49 const index = column * ROWS + row;
50 const symbol = active
51 ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length]
52 : flat.length === REELS * ROWS
53 ? flat[index]
54 : flat.length === REELS
55 ? flat[column]
56 : SYMBOLS[(index * 5 + column) % SYMBOLS.length];
57 const tile = node("div", "tile", symbol);
58 tile.classList.add(`sym-${symbol}`);
59 if (active) tile.classList.add("spinning");
60 if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win");
61 tiles.push(tile);
62 reel.append(tile);
63 }
64 reels.append(reel);
65 }
66 board.append(reels);
67
68 const lines = node("div", "win-lines");
69 if (!active && view.phase === "result") {
70 const winnings = Number(view.winnings ?? 0);
71 if (winnings > 0) {
72 const names = (view.win_lines ?? [])
73 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`)
74 .join(" · ");
75 lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`;
76 lines.classList.add("win-text");
77 } else {
78 lines.textContent = "No win — spin again.";
79 }
80 } else if (active) {
81 lines.textContent = "Spinning…";
82 } else {
83 lines.textContent = "Pick a bet and spin.";
84 }
85 board.append(lines);
86
87 if (active) {
88 let tick = 0;
89 animation = window.setInterval(() => {
90 tick += 1;
91 for (let index = 0; index < tiles.length; index += 1) {
92 const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length];
93 tiles[index].textContent = symbol;
94 tiles[index].className = `tile spinning sym-${symbol}`;
95 }
96 if (Date.now() >= spinUntil && latest && latestBoard) {
97 window.clearInterval(animation);
98 animation = undefined;
99 renderSlots(latestBoard, side, latest, send);
100 }
101 }, 70);
102 }
103
104 // Sidebar
105 side.append(node("div", "label", "Slots"));
106 side.append(node("p", "note", "5 reels × 3 rows, 5 paylines."));
107
108 const betRow = node("div", "row");
109 const minus = node("button", "btn small", "−10");
110 const bet = node("strong", "mono", money(Number(view.bet ?? 0)));
111 const plus = node("button", "btn small", "+10");
112 minus.disabled = active;
113 plus.disabled = active;
114 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
115 plus.addEventListener("click", () => send({ AdjustBet: 10 }));
116 betRow.append(minus, bet, plus);
117 side.append(node("div", "label", "Bet"), betRow);
118
119 if (view.error) {
120 side.append(node("p", "error-text", String(view.error)));
121 }
122
123 const status = node("p", "note");
124 if (active) status.textContent = "Spinning…";
125 else if (view.phase === "result") {
126 const winnings = Number(view.winnings ?? 0);
127 status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin.";
128 if (winnings > 0) status.classList.add("win-text");
129 } else status.textContent = "Ready when you are.";
130 side.append(status);
131
132 const actions = node("div", "actions");
133 const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN");
134 spin.disabled = active;
135 spin.addEventListener("click", () => {
136 requestSlotsSpin();
137 send("Spin");
138 });
139 const leave = node("button", "btn ghost wide", "Leave machine");
140 leave.disabled = active;
141 leave.addEventListener("click", () => send("Leave"));
142 actions.append(spin, leave);
143 side.append(actions);
144}