├── .gitignore
├── LICENSE
├── README.md
├── client
├── .eslintrc.cjs
├── .gitignore
├── .prettierrc
├── index.html
├── package-lock.json
├── package.json
├── src
│ ├── App.tsx
│ ├── GameClient
│ │ ├── DataModel.ts
│ │ ├── GameClient.ts
│ │ └── TypedEventEmitter.ts
│ ├── GameSettingsView
│ │ └── GameSettings.tsx
│ ├── GameView
│ │ ├── GameView.tsx
│ │ └── pages
│ │ │ ├── ConnectingPage
│ │ │ ├── ConnectingPage.tsx
│ │ │ └── index.ts
│ │ │ ├── ErrorPage
│ │ │ ├── ErrorPage.tsx
│ │ │ └── index.ts
│ │ │ ├── FakeUserSelector
│ │ │ ├── FakeUserSelector.tsx
│ │ │ └── index.ts
│ │ │ ├── GamePage
│ │ │ ├── GamePage.tsx
│ │ │ ├── UI
│ │ │ │ ├── Board
│ │ │ │ │ ├── Board.tsx
│ │ │ │ │ ├── BoardWrapper.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── GameControlPanel
│ │ │ │ │ ├── GameControlPanel.tsx
│ │ │ │ │ ├── GiveUpPopup.tsx
│ │ │ │ │ ├── PlayerActionsBlock.tsx
│ │ │ │ │ ├── SpectatorsBlock.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── GameResultPopup
│ │ │ │ │ ├── GameResultPopup.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ └── PlayerBar
│ │ │ │ │ ├── PlayerBar.tsx
│ │ │ │ │ └── PlayerTimer.tsx
│ │ │ └── index.ts
│ │ │ ├── WaitingPage
│ │ │ ├── WaitingPage.tsx
│ │ │ └── index.ts
│ │ │ └── index.ts
│ ├── Telegram
│ │ ├── TelegramThemeProvider.tsx
│ │ └── Types.ts
│ ├── UI
│ │ ├── Telegram
│ │ │ ├── ColorPicker
│ │ │ │ ├── ColorPicker.tsx
│ │ │ │ ├── assets
│ │ │ │ │ ├── king-outlined.svg
│ │ │ │ │ └── question-mark.svg
│ │ │ │ └── index.ts
│ │ │ ├── Menu
│ │ │ │ ├── Menu.tsx
│ │ │ │ └── index.ts
│ │ │ ├── MenuDivider
│ │ │ │ ├── MenuDivider.tsx
│ │ │ │ └── index.ts
│ │ │ ├── MenuItem
│ │ │ │ ├── MenuItem.tsx
│ │ │ │ └── index.ts
│ │ │ ├── MenuItemSlider
│ │ │ │ ├── MenuItemSlider.tsx
│ │ │ │ └── index.ts
│ │ │ ├── MenuItemSwitch
│ │ │ │ ├── MenuItemSwitch.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── ThinkingIndicator
│ │ │ ├── ThinkingIndicator.tsx
│ │ │ └── index.ts
│ │ ├── UserAvatar
│ │ │ ├── UserAvatar.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── assets
│ │ └── audio
│ │ │ └── move.wav
│ ├── i18n.ts
│ ├── index.css
│ ├── index.tsx
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
└── server
├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── config
└── default.json5
├── locales
├── en.json
├── ru.json
└── uk.json
├── package-lock.json
├── package.json
├── src
├── GameServer
│ ├── DataModel.ts
│ ├── Database.ts
│ ├── Errors.ts
│ ├── GameServer.ts
│ ├── PausableTimer.ts
│ ├── ServerRoom.ts
│ ├── SocketErrorHandler.ts
│ └── TypedEventEmitter.ts
├── Telegram
│ ├── ChessNowBot.ts
│ ├── MessageGenerator.ts
│ ├── Types.ts
│ ├── joinFullname.ts
│ ├── parseGameRulesQuery.ts
│ └── parseInitData.ts
├── i18n.ts
└── index.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Quatern1on
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/client/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {browser: true, es2020: true},
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh', 'simple-import-sort'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | {allowConstantExport: true},
16 | ],
17 | "simple-import-sort/imports": "error",
18 | "@typescript-eslint/no-unused-vars": "off",
19 | "@typescript-eslint/no-explicit-any": "off"
20 | },
21 | }
22 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | build
12 | dist
13 | dist-ssr
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
27 | .env
--------------------------------------------------------------------------------
/client/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "trailingComma": "es5",
4 | "tabWidth": 4,
5 | "singleQuote": false,
6 | "bracketSameLine": true,
7 | "bracketSpacing": false
8 | }
9 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Chess Now
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "start": "npm run dev",
9 | "build": "tsc && vite build",
10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
11 | "preview": "vite preview"
12 | },
13 | "dependencies": {
14 | "@emotion/react": "^11.11.1",
15 | "@emotion/styled": "^11.11.0",
16 | "@fontsource/roboto": "^5.0.8",
17 | "@mui/icons-material": "^5.14.11",
18 | "@mui/material": "^5.14.11",
19 | "@types/events": "^3.0.1",
20 | "chess.js": "^1.0.0-beta.6",
21 | "events": "^3.3.0",
22 | "i18next": "^23.5.1",
23 | "react": "^18.2.0",
24 | "react-chessboard": "^4.2.1",
25 | "react-countdown": "^2.3.5",
26 | "react-dom": "^18.2.0",
27 | "react-i18next": "^13.2.2",
28 | "socket.io-client": "^4.7.2",
29 | "tinycolor2": "^1.6.0"
30 | },
31 | "devDependencies": {
32 | "@types/node": "^20.7.0",
33 | "@types/react": "^18.2.15",
34 | "@types/react-dom": "^18.2.7",
35 | "@types/tinycolor2": "^1.4.4",
36 | "@typescript-eslint/eslint-plugin": "^6.0.0",
37 | "@typescript-eslint/parser": "^6.0.0",
38 | "@vitejs/plugin-basic-ssl": "^1.0.1",
39 | "@vitejs/plugin-react": "^4.0.3",
40 | "eslint": "^8.45.0",
41 | "eslint-plugin-react-hooks": "^4.6.0",
42 | "eslint-plugin-react-refresh": "^0.4.3",
43 | "eslint-plugin-simple-import-sort": "^10.0.0",
44 | "prettier": "^3.0.3",
45 | "typescript": "^5.0.2",
46 | "vite": "^4.4.5",
47 | "vite-plugin-checker": "^0.6.1",
48 | "vite-plugin-handlebars": "^1.6.0",
49 | "vite-plugin-svgr": "^3.2.0",
50 | "vite-tsconfig-paths": "^4.2.0"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import {FakeUserSelector} from "GameView/pages/FakeUserSelector";
2 | import type {FC} from "react";
3 |
4 | import {GameSettingsView} from "@/GameSettingsView/GameSettings";
5 | import {GameView} from "@/GameView/GameView";
6 | import {TelegramThemeProvider} from "@/Telegram/TelegramThemeProvider.js";
7 |
8 | export const App: FC = () => {
9 | if (
10 | window.Telegram.WebApp.initDataUnsafe.start_param ||
11 | (import.meta.env.VITE_ROOM_ID && window.Telegram.WebApp.platform === "unknown")
12 | ) {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | return (
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/client/src/GameClient/DataModel.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Color of the chest pieces
3 | */
4 | export enum Color {
5 | White = "w",
6 | Black = "b",
7 | }
8 |
9 | /**
10 | * A 8x8 board square address in Algebraic notation
11 | * @see {@link https://en.wikipedia.org/wiki/Algebraic_notation_(chess)}
12 | */
13 | export enum Square {
14 | A8 = "a8",
15 | B8 = "b8",
16 | C8 = "c8",
17 | D8 = "d8",
18 | E8 = "e8",
19 | F8 = "f8",
20 | G8 = "g8",
21 | H8 = "h8",
22 | A7 = "a7",
23 | B7 = "b7",
24 | C7 = "c7",
25 | D7 = "d7",
26 | E7 = "e7",
27 | F7 = "f7",
28 | G7 = "g7",
29 | H7 = "h7",
30 | A6 = "a6",
31 | B6 = "b6",
32 | C6 = "c6",
33 | D6 = "d6",
34 | E6 = "e6",
35 | F6 = "f6",
36 | G6 = "g6",
37 | H6 = "h6",
38 | A5 = "a5",
39 | B5 = "b5",
40 | C5 = "c5",
41 | D5 = "d5",
42 | E5 = "e5",
43 | F5 = "f5",
44 | G5 = "g5",
45 | H5 = "h5",
46 | A4 = "a4",
47 | B4 = "b4",
48 | C4 = "c4",
49 | D4 = "d4",
50 | E4 = "e4",
51 | F4 = "f4",
52 | G4 = "g4",
53 | H4 = "h4",
54 | A3 = "a3",
55 | B3 = "b3",
56 | C3 = "c3",
57 | D3 = "d3",
58 | E3 = "e3",
59 | F3 = "f3",
60 | G3 = "g3",
61 | H3 = "h3",
62 | A2 = "a2",
63 | B2 = "b2",
64 | C2 = "c2",
65 | D2 = "d2",
66 | E2 = "e2",
67 | F2 = "f2",
68 | G2 = "g2",
69 | H2 = "h2",
70 | A1 = "a1",
71 | B1 = "b1",
72 | C1 = "c1",
73 | D1 = "d1",
74 | E1 = "e1",
75 | F1 = "f1",
76 | G1 = "g1",
77 | H1 = "h1",
78 | }
79 |
80 | /**
81 | * Piece symbol (without color)
82 | */
83 | export enum PieceSymbol {
84 | Pawn = "p",
85 | Knight = "n",
86 | Bishop = "b",
87 | Rook = "r",
88 | Queen = "q",
89 | King = "k",
90 | }
91 |
92 | /**
93 | * Represents a user authenticated through Telegram
94 | */
95 | export interface User {
96 | /**
97 | * Telegram user's id
98 | * @see {@link https://core.telegram.org/bots/api#user}
99 | */
100 | id: number;
101 |
102 | /**
103 | * Telegram user's first_name and last_name joined
104 | * @see {@link https://core.telegram.org/bots/api#user}
105 | */
106 | fullName: string;
107 |
108 | /**
109 | * Telegram user's username
110 | * @see {@link https://core.telegram.org/bots/api#user}
111 | */
112 | username?: string;
113 |
114 | /**
115 | * Data-URL of Telegram user's first profile picture
116 | */
117 | avatarURL?: string;
118 | }
119 |
120 | /**
121 | * Represents dynamic state of room member
122 | */
123 | export interface MemberState {
124 | /**
125 | * Whether the member has active connection to the room. A member could be present in the room but not connected,
126 | * for example if he/she leaves mid-game.
127 | *
128 | * Also, the user who created the room (the host) will always be present in the room (even if not connected)
129 | */
130 | connected: boolean;
131 |
132 | /**
133 | * Whether the member has ability to participate in the game (e.g. make moves). All members have ability to watch
134 | * the game by default.
135 | */
136 | isPlayer: boolean;
137 |
138 | /**
139 | * The color of the chest pieces the player controls. Guaranteed to be unique in the room.
140 | *
141 | * Present if {@link isPlayer} equals true
142 | */
143 | color?: Color;
144 | }
145 |
146 | /**
147 | * Represents a member of the room and its state
148 | * @see Room
149 | */
150 | export interface Member {
151 | /**
152 | * User profile of this member
153 | */
154 | user: User;
155 |
156 | /**
157 | * Member's state
158 | */
159 | state: MemberState;
160 | }
161 |
162 | /**
163 | * Represents the parameters of the game in current room, that could be set up before room creation
164 | * @see Room.gameRules
165 | */
166 | export interface GameRules {
167 | /**
168 | * If host selected to play as a specific color, this field is equal to that color. Undefined is it should be picked
169 | * randomly.
170 | */
171 | hostPreferredColor?: Color;
172 |
173 | /**
174 | * Whether players are limited in time to make a move. The timer does not reset when player makes a move. If the
175 | * player runs out of time he/she looses.
176 | */
177 | timer: boolean;
178 |
179 | /**
180 | * Initial time on the timer for each player. Expressed in seconds.
181 | *
182 | * Present if {@link timer} equals true.
183 | */
184 | initialTime?: number;
185 |
186 | /**
187 | * Amount by which the timer of the player should be incremented after he/she made a move
188 | *
189 | * Present if {@link plays} equals true.
190 | */
191 | timerIncrement?: number;
192 | }
193 |
194 | /**
195 | * Status of the game in the current room
196 | * @see GameState.status
197 | */
198 | export enum GameStatus {
199 | NotStarted = "not-started",
200 | InProgress = "in-progress",
201 | Finished = "finished",
202 | }
203 |
204 | /**
205 | * The result which the game was finished with
206 | */
207 | export enum GameResolution {
208 | /**
209 | * Means in any game position a player's king is in check
210 | *
211 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
212 | * @see {@link https://en.wikipedia.org/wiki/Checkmate}
213 | */
214 | Checkmate = "checkmate",
215 |
216 | /**
217 | * One of the players run out of timer time
218 | *
219 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
220 | */
221 | OutOfTime = "out-of-time",
222 |
223 | /**
224 | * One of the players disconnected from the game and did not reconnect back in time
225 | *
226 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
227 | */
228 | PlayerQuit = "player-quit",
229 |
230 | /**
231 | * One of the players gave up
232 | *
233 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
234 | */
235 | GiveUp = "give-up",
236 |
237 | /**
238 | * A situation where the player whose turn it is to move is not in check and has no legal move
239 | *
240 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set
241 | * @see https://en.wikipedia.org/wiki/Stalemate
242 | */
243 | Stalemate = "stalemate",
244 |
245 | /**
246 | * Draw
247 | *
248 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set
249 | * @see https://en.wikipedia.org/wiki/Draw_(chess)
250 | */
251 | Draw = "draw",
252 | }
253 |
254 | /**
255 | * State of the game in current room
256 | */
257 | export interface GameState {
258 | /**
259 | * Whether the game not-started, started or finished
260 | * @see GameStatus
261 | */
262 | status: GameStatus;
263 |
264 | /**
265 | * History of all moves in PGN notation
266 | * @see {@link https://en.wikipedia.org/wiki/Portable_Game_Notation}
267 | */
268 | pgn: string;
269 |
270 | /**
271 | * Whose side the current turn is
272 | */
273 | turn: Color;
274 |
275 | /**
276 | * State of player's timers. Defined if {@link GameRules.timer} equals true.
277 | */
278 | timer?: TimerState;
279 |
280 | /**
281 | * How the game finished
282 | *
283 | * Present if {@link status} equals {@link GameStatus.Finished}
284 | */
285 | resolution?: GameResolution;
286 |
287 | /**
288 | * If the game finished in a win of one player, the ID of the user, who won the game
289 | */
290 | winnerID?: number;
291 | }
292 |
293 | /**
294 | * Represents a game session, which players or spectators could connect
295 | */
296 | export interface Room {
297 | /**
298 | * Unique string identifier 24 characters long
299 | */
300 | id: string;
301 |
302 | /**
303 | * List of members currently present in the room. Not all present members are guaranteed to be connected.
304 | * @see MemberState.connected
305 | */
306 | members: Member[];
307 |
308 | /**
309 | * ID of the user who created the room. Guaranteed to be present in the {@link members}.
310 | * @see User.id
311 | */
312 | hostID: number;
313 |
314 | /**
315 | * UNIX time (number of seconds since 00:00:00 UTC January 1, 1970) of the moment, when this room was created
316 | * @see {@link https://en.wikipedia.org/wiki/Unix_time}
317 | */
318 | createdTimestamp: number;
319 |
320 | /**
321 | * Parameters of the game
322 | * @see GameRules
323 | */
324 | gameRules: GameRules;
325 |
326 | /**
327 | * State of the game
328 | * @see GameState
329 | */
330 | gameState: GameState;
331 | }
332 |
333 | /**
334 | * Represents a chess move
335 | */
336 | export interface Move {
337 | /**
338 | * Which square the piece was moved from
339 | */
340 | from: Square;
341 |
342 | /**
343 | * Which square the piece was moved to
344 | */
345 | to: Square;
346 |
347 | /**
348 | * Present if the pawn was promoted to another piece. Contains which piece it was promoted to.
349 | */
350 | promotion?: PieceSymbol;
351 | }
352 |
353 | /**
354 | * Represents a state of two player timers
355 | */
356 | export interface TimerState {
357 | /**
358 | * Amount of time in milliseconds left on white player's timer
359 | */
360 | whiteTimeLeft: number;
361 |
362 | /**
363 | * Amount of time in milliseconds left on black player's timer
364 | */
365 | blackTimeLeft: number;
366 | }
367 |
368 | /**
369 | * Payload sent by client with new connection
370 | */
371 | export interface AuthPayload {
372 | /**
373 | * initData field from window.Telegram.WebApp in the client. Needed fot authentication. Its genuinity will be
374 | * verified by the server.
375 | * @see {@link https://core.telegram.org/bots/webapps#initializing-mini-apps}
376 | * @see {@link https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app}
377 | */
378 | initData: string;
379 | }
380 |
381 | /**
382 | * List of events that server could send to connected client
383 | */
384 | export interface ServerToClientEvents {
385 | /**
386 | * Sent when error happens while processing client event. After an error happens, server closes the connection with
387 | * the client.
388 | * @param name Name of the Error class
389 | * @param message Message explaining the details of the error in english
390 | */
391 | error: (name: string, message: string) => void;
392 |
393 | /**
394 | * Sent to client, when it initially connects to the room
395 | * @param room Room details the client connected to
396 | * @param userID ID of the user this connection is assigned to
397 | */
398 | init: (room: Room, userID: number) => void;
399 |
400 | /**
401 | * Sent when another user joins the room
402 | * @param user The user who joined the room
403 | */
404 | memberJoin: (member: Member) => void;
405 |
406 | /**
407 | * Sent when the members, present in the room leaves it. When the game begins, users who became players
408 | * ({@link MemberState.isPlayer} equals true) can not leave, they only can disconnect, and
409 | * {@link ServerToClientEvents.memberUpdate} will be called in this case.
410 | * @param userID ID of the user who left the room
411 | */
412 | memberLeave: (userID: number) => void;
413 |
414 | /**
415 | * Sent when the member's state, that is currently present in the room updated
416 | * @param userID ID of the user, whose state is updated
417 | * @param state New state of the member
418 | */
419 | memberUpdate: (userID: number, state: MemberState) => void;
420 |
421 | /**
422 | * Sent when current gameState of the room transitions from {@link GameStatus.NotStarted} to
423 | * {@link GameStatus.InProgress}
424 | */
425 | gameStart: () => void;
426 |
427 | /**
428 | * Sent when current gameState of the room transitions from {@link GameStatus.InProgress} to
429 | * {@link GameStatus.Finished}
430 | * @param resolution How the game ended
431 | * @param winnerID ID of the user who won
432 | * @param timer Last timer recordings. Defined if {@link GameRules.timer} equals true.
433 | * @see GameState.winnerID
434 | */
435 | gameEnd: (resolution: GameResolution, winnerID?: number, timer?: TimerState) => void;
436 |
437 | /**
438 | * Sent when a move was registered by server
439 | * @param move Move that was registered
440 | * @param timer State of player's timers. Defined if {@link GameRules.timer} equals true.
441 | */
442 | move: (move: Move, timer?: TimerState) => void;
443 | }
444 |
445 | /**
446 | * List of events that client could send to server
447 | */
448 | export interface ClientToServerEvents {
449 | /**
450 | * Make a move. The move should be validated on both server and client sides. If the move is illegal, or it is not
451 | * the players turn, server will return an error and close the connection.
452 | * @param move A move requested by client
453 | */
454 | makeMove: (move: Move) => void;
455 |
456 | /**
457 | * User agreed to take a loss and voluntarily give up
458 | */
459 | giveUp: () => void;
460 | }
461 |
--------------------------------------------------------------------------------
/client/src/GameClient/GameClient.ts:
--------------------------------------------------------------------------------
1 | import * as ChessJS from "chess.js";
2 | import {Chess} from "chess.js";
3 | import * as SocketIO from "socket.io-client";
4 |
5 | import {
6 | ClientToServerEvents,
7 | Color,
8 | GameResolution,
9 | GameState,
10 | GameStatus,
11 | Member,
12 | MemberState,
13 | Move,
14 | PieceSymbol,
15 | Room,
16 | ServerToClientEvents,
17 | Square,
18 | TimerState,
19 | User,
20 | } from "@/GameClient/DataModel";
21 | import {TypedEventEmitter} from "@/GameClient/TypedEventEmitter";
22 |
23 | export interface ErrorState {
24 | isError: boolean;
25 | serverSide?: boolean;
26 | name?: string;
27 | message?: string;
28 | }
29 |
30 | export interface GameClientEvents {
31 | errorUpdate: [serverSide: boolean, name: string, message: string];
32 | anyUpdate: [state: ClientState];
33 | move: [move: Move];
34 | }
35 |
36 | export interface ClientRoom extends Omit {
37 | members: {[key: number]: Member};
38 | gameState: ClientGameState;
39 | me: Member;
40 | opponent?: Member;
41 | whitePlayer?: Member;
42 | blackPlayer?: Member;
43 | }
44 |
45 | export interface ClientGameState extends Omit {
46 | fen: string;
47 | lastMove?: Move;
48 | winner?: User;
49 | }
50 |
51 | export interface ClientState {
52 | room?: ClientRoom;
53 | error: ErrorState;
54 | makingMove: boolean;
55 | connected: boolean;
56 | }
57 |
58 | export interface PossibleMove {
59 | to: Square;
60 | promotion: boolean;
61 | }
62 |
63 | type Socket = SocketIO.Socket;
64 |
65 | export class GameClient extends TypedEventEmitter {
66 | private _state: ClientState;
67 |
68 | private readonly socket: Socket;
69 |
70 | private readonly chess: Chess;
71 |
72 | public static swapColor(color: Color): Color {
73 | return color === Color.White ? Color.Black : Color.White;
74 | }
75 |
76 | public constructor() {
77 | super();
78 |
79 | this.socket = SocketIO.io(import.meta.env.VITE_SERVER_URL, {
80 | auth: {
81 | initData: window.Telegram.WebApp.initData || window.fakeInitData,
82 | },
83 | transports: ["polling", "websocket"],
84 | });
85 | this.chess = new Chess();
86 |
87 | this._state = {
88 | connected: false,
89 | error: {isError: false},
90 | makingMove: false,
91 | };
92 |
93 | this.socket.on("error", this.handleServerError);
94 | this.socket.on("init", this.handleInit);
95 | this.socket.on("memberJoin", this.handleMemberJoin);
96 | this.socket.on("memberLeave", this.handleMemberLeave);
97 | this.socket.on("memberUpdate", this.handleMemberUpdate);
98 | this.socket.on("gameStart", this.handleGameStart);
99 | this.socket.on("gameEnd", this.handleGameEnd);
100 | this.socket.on("move", this.handleMove);
101 | this.socket.on("disconnect", this.handleDisconnect);
102 | }
103 |
104 | get state(): ClientState {
105 | return this._state;
106 | }
107 |
108 | public readonly makeMove = (move: Move): boolean => {
109 | if (
110 | this.chess.turn() !== this._state.room!.me.state.color ||
111 | this._state.room!.gameState.status !== GameStatus.InProgress
112 | ) {
113 | return false;
114 | }
115 |
116 | try {
117 | this.chess.move(move);
118 | this._state.makingMove = true;
119 | this.socket.emit("makeMove", move);
120 | this.chess.undo();
121 | return true;
122 | } catch (e) {
123 | return false;
124 | }
125 | };
126 |
127 | public readonly disconnect = () => {
128 | this.socket.disconnect();
129 | this._state.connected = false;
130 |
131 | this.emit("anyUpdate", this._state);
132 | };
133 |
134 | public getPossibleMoves = (from: Square): PossibleMove[] => {
135 | const squareToMoveMap: {[key: string]: PossibleMove} = {};
136 | const moves = this.chess.moves({verbose: true, square: from});
137 | for (const move of moves) {
138 | if (!squareToMoveMap[move.to]) {
139 | squareToMoveMap[move.to] = {
140 | to: move.to as Square,
141 | promotion: false,
142 | };
143 | }
144 |
145 | if (move.promotion) {
146 | squareToMoveMap[move.to].promotion = true;
147 | }
148 | }
149 |
150 | return Object.values(squareToMoveMap);
151 | };
152 |
153 | public getCheck = (): Square | undefined => {
154 | if (this.chess.inCheck()) {
155 | const board = this.chess.board();
156 | const color = this.chess.turn();
157 | for (const row of board) {
158 | for (const square of row) {
159 | if (square?.type === ChessJS.KING && square.color === color) {
160 | return square.square as Square;
161 | }
162 | }
163 | }
164 | }
165 | };
166 |
167 | public getPieceColor = (square: Square): Color | undefined => {
168 | return this.chess.get(square as ChessJS.Square).color as Color;
169 | };
170 |
171 | public readonly giveUp = (): void => {
172 | this.socket.emit("giveUp");
173 | };
174 |
175 | private readonly handleServerError = (name: string, message: string): void => {
176 | this._state.error.isError = true;
177 | this._state.error.serverSide = true;
178 | this._state.error.name = name;
179 | this._state.error.message = message;
180 |
181 | this.emit("errorUpdate", true, name, message);
182 | this.emit("anyUpdate", this._state);
183 | };
184 |
185 | private readonly findPlayers = (): void => {
186 | const me = this._state.room!.me;
187 |
188 | for (const member of Object.values(this._state.room!.members)) {
189 | if (member.user.id !== me.user.id && member.state.isPlayer && me.state.isPlayer) {
190 | this._state.room!.opponent = member;
191 | }
192 | if (member.state.isPlayer && member.state.color === Color.White) {
193 | this._state.room!.whitePlayer = member;
194 | }
195 | if (member.state.isPlayer && member.state.color === Color.Black) {
196 | this._state.room!.blackPlayer = member;
197 | }
198 | }
199 | };
200 |
201 | private readonly handleInit = (room: Room, userID: number): void => {
202 | const membersDict = {};
203 | let me: Member;
204 | const opponent: Member | undefined = undefined;
205 |
206 | for (const member of room.members) {
207 | membersDict[member.user.id] = member;
208 | if (member.user.id === userID) {
209 | me = member;
210 | }
211 | }
212 |
213 | this.chess.loadPgn(room.gameState.pgn);
214 |
215 | const clientGameState: ClientGameState = {
216 | ...room.gameState,
217 | fen: this.chess.fen(),
218 | };
219 |
220 | const history = this.chess.history({verbose: true});
221 | const lastHistoryEntry = history[history.length - 1];
222 | if (lastHistoryEntry) {
223 | clientGameState.lastMove = {
224 | from: lastHistoryEntry.from as Square,
225 | to: lastHistoryEntry.to as Square,
226 | promotion: lastHistoryEntry.promotion as PieceSymbol,
227 | };
228 | }
229 |
230 | if (room.gameState.winnerID) {
231 | clientGameState.winner = membersDict[room.gameState.winnerID].user;
232 | }
233 |
234 | const clientRoom: ClientRoom = {
235 | id: room.id,
236 | members: membersDict,
237 | hostID: room.hostID,
238 | createdTimestamp: room.createdTimestamp,
239 | gameRules: room.gameRules,
240 | gameState: clientGameState,
241 | me: me!,
242 | opponent: opponent,
243 | };
244 |
245 | this._state.makingMove = false;
246 | this._state.error = {isError: false};
247 | this._state.connected = true;
248 | this._state.room = clientRoom;
249 |
250 | this.findPlayers();
251 |
252 | this.emit("anyUpdate", this._state);
253 | };
254 |
255 | private readonly handleMemberJoin = (member: Member): void => {
256 | this._state.room!.members[member.user.id] = member;
257 | this.findPlayers();
258 | this.emit("anyUpdate", this._state);
259 | };
260 |
261 | private readonly handleMemberLeave = (userID: number): void => {
262 | delete this._state.room!.members[userID];
263 | this.emit("anyUpdate", this._state);
264 | };
265 |
266 | private readonly handleMemberUpdate = (userID: number, state: MemberState): void => {
267 | this._state.room!.members[userID].state = state;
268 | this.findPlayers();
269 | this.emit("anyUpdate", this._state);
270 | };
271 |
272 | private readonly handleGameStart = (): void => {
273 | this._state.room!.gameState.status = GameStatus.InProgress;
274 | if (this._state.room!.gameRules.timer) {
275 | this._state.room!.gameState.timer = {
276 | whiteTimeLeft: this._state.room!.gameRules.initialTime! * 1000,
277 | blackTimeLeft: this._state.room!.gameRules.initialTime! * 1000,
278 | };
279 | }
280 | this.emit("anyUpdate", this._state);
281 | };
282 |
283 | private readonly handleGameEnd = (resolution: GameResolution, winnerID?: number, timer?: TimerState): void => {
284 | this._state.room!.gameState.status = GameStatus.Finished;
285 | this._state.room!.gameState.resolution = resolution;
286 | this._state.room!.gameState.winnerID = winnerID;
287 | if (winnerID) {
288 | this._state.room!.gameState.winner = this._state.room!.members[winnerID].user;
289 | }
290 | if (timer) {
291 | this._state.room!.gameState.timer = timer;
292 | }
293 | this.emit("anyUpdate", this._state);
294 | };
295 |
296 | private readonly handleMove = (move: Move, timer?: TimerState): void => {
297 | this.chess.move(move);
298 |
299 | this._state.room!.gameState.fen = this.chess.fen();
300 | this._state.room!.gameState.turn = this.chess.turn() as Color;
301 | this._state.room!.gameState.lastMove = move;
302 | this._state.makingMove = false;
303 | if (timer) {
304 | this._state.room!.gameState.timer = timer;
305 | }
306 |
307 | this.emit("move", move);
308 | this.emit("anyUpdate", this._state);
309 | };
310 |
311 | private readonly handleDisconnect = (): void => {
312 | this._state.room = undefined;
313 | this._state.connected = false;
314 | this.emit("anyUpdate", this._state);
315 | };
316 | }
317 |
--------------------------------------------------------------------------------
/client/src/GameClient/TypedEventEmitter.ts:
--------------------------------------------------------------------------------
1 | import {EventEmitter} from "events";
2 |
3 | export class TypedEventEmitter> {
4 | private emitter = new EventEmitter();
5 |
6 | protected emit(eventName: TEventName, ...eventArg: TEvents[TEventName]) {
7 | this.emitter.emit(eventName, ...(eventArg as []));
8 | }
9 |
10 | public on(
11 | eventName: TEventName,
12 | handler: (...eventArg: TEvents[TEventName]) => void
13 | ) {
14 | this.emitter.on(eventName, handler as unknown as any);
15 | }
16 |
17 | public off(
18 | eventName: TEventName,
19 | handler: (...eventArg: TEvents[TEventName]) => void
20 | ) {
21 | this.emitter.off(eventName, handler as any);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/GameSettingsView/GameSettings.tsx:
--------------------------------------------------------------------------------
1 | import SwapVertOutlinedIcon from "@mui/icons-material/SwapVertOutlined";
2 | import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined";
3 | import {Collapse, Container} from "@mui/material";
4 | import {FC, useEffect, useState} from "react";
5 | import {useTranslation} from "react-i18next";
6 |
7 | import {
8 | ColorPicker,
9 | ColorPickerValueType,
10 | Menu,
11 | MenuDivider,
12 | MenuItem,
13 | MenuItemSlider,
14 | MenuItemSwitch,
15 | } from "@/UI/Telegram";
16 |
17 | const initTimeSlider = [
18 | {text: "1/4", value: 15},
19 | {text: "1/2", value: 30},
20 | {text: "3/4", value: 45},
21 | {text: "1", value: 60},
22 | {text: "2", value: 60 * 2},
23 | {text: "3", value: 60 * 3},
24 | {text: "5", value: 60 * 5},
25 | {text: "10", value: 60 * 10},
26 | {text: "15", value: 60 * 15},
27 | {text: "20", value: 60 * 20},
28 | {text: "30", value: 60 * 30},
29 | {text: "45", value: 60 * 45},
30 | {text: "60", value: 60 * 60},
31 | {text: "90", value: 60 * 90},
32 | {text: "120", value: 60 * 120},
33 | {text: "180", value: 60 * 180},
34 | ];
35 |
36 | const incrementTimeSlider = [
37 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 25, 30, 35, 40, 45, 50, 55, 60, 90, 120,
38 | 150, 180,
39 | ];
40 |
41 | export const GameSettingsView: FC = () => {
42 | const {t} = useTranslation();
43 |
44 | const [enableTimer, setEnableTimer] = useState(true);
45 | const [initTimeIndex, setInitTimeIndex] = useState(7);
46 | const [incrementTimeIndex, setIncrementTimeIndex] = useState(0);
47 | const [playerColor, setPlayerColor] = useState("random");
48 |
49 | useEffect(() => {
50 | window.Telegram.WebApp.MainButton.setText(t("settings.done"));
51 | window.Telegram.WebApp.MainButton.show();
52 | }, [t]);
53 |
54 | useEffect(() => {
55 | const generateInlineQuery = (): string => {
56 | let timerToken = enableTimer ? "1:" : "0:";
57 |
58 | if (enableTimer) {
59 | timerToken += initTimeSlider[initTimeIndex].value.toString() + ":";
60 | timerToken += incrementTimeSlider[incrementTimeIndex].toString() + ":";
61 | }
62 |
63 | const colorToken = playerColor.slice(0, 1);
64 | return "$" + timerToken + colorToken;
65 | };
66 |
67 | const handleMainButtonClick = () => {
68 | window.Telegram.WebApp.switchInlineQuery(generateInlineQuery());
69 | };
70 |
71 | window.Telegram.WebApp.MainButton.onClick(handleMainButtonClick);
72 | return () => {
73 | window.Telegram.WebApp.MainButton.offClick(handleMainButtonClick);
74 | };
75 | }, [enableTimer, initTimeIndex, incrementTimeIndex, playerColor]);
76 |
77 | return (
78 |
79 |
120 |
121 | );
122 | };
123 |
--------------------------------------------------------------------------------
/client/src/GameView/GameView.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useEffect, useMemo, useState} from "react";
2 |
3 | import moveSound from "@/assets/audio/move.wav";
4 | import {GameStatus} from "@/GameClient/DataModel";
5 | import {ClientState, GameClient} from "@/GameClient/GameClient";
6 |
7 | import {ConnectingPage, ErrorPage, GamePage, WaitingPage} from "./pages";
8 |
9 | const moveSoundPlayer = new Audio(moveSound);
10 |
11 | export const GameView: FC = () => {
12 | const [clientState, setClientState] = useState();
13 |
14 | const client = useMemo(() => new GameClient(), []);
15 |
16 | const handleAnyUpdate = (state: ClientState) => {
17 | setClientState({...state});
18 | };
19 |
20 | const handleMove = () => {
21 | moveSoundPlayer.play();
22 | };
23 |
24 | useEffect(() => {
25 | client.on("anyUpdate", handleAnyUpdate);
26 | client.on("move", handleMove);
27 |
28 | return () => {
29 | client.disconnect();
30 | };
31 | // eslint-disable-next-line react-hooks/exhaustive-deps
32 | }, []);
33 |
34 | if (!clientState) {
35 | return ;
36 | }
37 |
38 | const error = clientState.error;
39 | const connected = clientState.connected;
40 | const room = clientState.room;
41 | const makingMove = clientState.makingMove;
42 |
43 | if (error.isError) {
44 | return ;
45 | }
46 |
47 | if (!connected || !room) {
48 | return ;
49 | }
50 |
51 | if (room.gameState.status === GameStatus.NotStarted) {
52 | return ;
53 | }
54 |
55 | return ;
56 | };
57 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/ConnectingPage/ConnectingPage.tsx:
--------------------------------------------------------------------------------
1 | import {CircularProgress, Stack, Typography} from "@mui/material";
2 | import type {FC} from "react";
3 | import {useTranslation} from "react-i18next";
4 |
5 | export const ConnectingPage: FC = () => {
6 | const {t} = useTranslation();
7 | return (
8 |
9 |
10 |
11 | {t("game.connecting")}
12 |
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/ConnectingPage/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ConnectingPage";
2 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/ErrorPage/ErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import {Container, Typography} from "@mui/material";
2 | import type {FC} from "react";
3 | import {useTranslation} from "react-i18next";
4 |
5 | export const ErrorPage: FC = () => {
6 | const {t} = useTranslation();
7 |
8 | return (
9 |
10 |
11 | {t("error.title")}
12 |
13 |
14 | {t("error.message")}
15 |
16 |
17 | {t("error.possibleSolutions")}
18 |
19 |
20 | -
21 | {t("error.checkInternet")}
22 |
23 | -
24 | {t("error.refreshPage")}
25 |
26 | -
27 | {t("error.contactSupport")}
28 |
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/ErrorPage/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ErrorPage";
2 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/FakeUserSelector/FakeUserSelector.tsx:
--------------------------------------------------------------------------------
1 | import {Stack, Typography} from "@mui/material";
2 | import React, {FC, ReactNode, useState} from "react";
3 |
4 | import {User} from "@/GameClient/DataModel";
5 | import {WebAppUser} from "@/Telegram/Types";
6 | import {UserAvatar} from "@/UI";
7 |
8 | export type FakeUserSelectorProps = {
9 | children?: ReactNode;
10 | };
11 |
12 | declare global {
13 | interface Window {
14 | fakeInitData?: string;
15 | }
16 | }
17 |
18 | const generateFakeInitData = (user: User, startParam: string) => {
19 | const fakeUser: WebAppUser = {
20 | id: user.id,
21 | first_name: user.fullName.split(" ", 2)[0],
22 | last_name: user.fullName.split(" ", 2)[1],
23 | username: user.username,
24 | language_code: "en",
25 | allows_write_to_pm: true,
26 | };
27 |
28 | return encodeURI(`user=${JSON.stringify(fakeUser)}&start_param=${startParam}`);
29 | };
30 |
31 | const fakeUsers: Array = [
32 | {
33 | id: 1,
34 | fullName: "John Doe",
35 | username: "john_doe",
36 | },
37 | {
38 | id: 2,
39 | fullName: "Michael Brown",
40 | username: "michael_brown",
41 | },
42 | {
43 | id: 3,
44 | fullName: "Leonard Nimoy",
45 | username: "leonard_nimoy",
46 | },
47 | ];
48 |
49 | export const FakeUserSelector: FC = ({children}) => {
50 | const [selected, setSelected] = useState(false);
51 | const initDataAvailable = Boolean(window.Telegram.WebApp.initData);
52 |
53 | if (selected || initDataAvailable || import.meta.env.PROD) {
54 | return <>{children}>;
55 | } else {
56 | return (
57 |
63 |
64 | Select fake user
65 |
66 |
67 | {fakeUsers.map((fakeUser) => (
68 | {
74 | window.fakeInitData = generateFakeInitData(fakeUser, import.meta.env.VITE_ROOM_ID);
75 | setSelected(true);
76 | }}>
77 |
78 | {fakeUser.fullName}
79 |
80 | ))}
81 |
82 |
83 | );
84 | }
85 | };
86 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/FakeUserSelector/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./FakeUserSelector";
2 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/GamePage.tsx:
--------------------------------------------------------------------------------
1 | import {Box} from "@mui/material";
2 | import React, {useEffect, useState} from "react";
3 |
4 | import {Color, GameStatus, Member} from "@/GameClient/DataModel";
5 | import {ClientRoom, GameClient} from "@/GameClient/GameClient";
6 | import {Board, PlayerBar} from "@/GameView/pages";
7 | import {GameControlPanel} from "@/GameView/pages/GamePage/UI/GameControlPanel";
8 | import {GameResultPopup} from "@/GameView/pages/GamePage/UI/GameResultPopup";
9 |
10 | export interface GameProps {
11 | room: ClientRoom;
12 | makingMove: boolean;
13 | gameClient: GameClient;
14 | }
15 |
16 | export const GamePage: React.FC = ({room, makingMove, gameClient}) => {
17 | const [isResultOpen, setIsResultOpen] = useState(room.gameState.status === GameStatus.Finished);
18 |
19 | const handleResultClose = () => {
20 | setIsResultOpen(false);
21 | };
22 |
23 | const handleResultOpen = () => {
24 | setIsResultOpen(true);
25 | };
26 |
27 | useEffect(() => {
28 | if (room.gameState.status === GameStatus.Finished) {
29 | handleResultOpen();
30 | }
31 | }, [room.gameState.status]);
32 |
33 | const [topPlayer, bottomPlayer]: [Member, Member] = room.me.state.isPlayer
34 | ? [room.opponent!, room.me]
35 | : [room.blackPlayer!, room.whitePlayer!];
36 |
37 | const isTopPlayerWhite = topPlayer.state.color === Color.White;
38 | const [topPlayerTimer, bottomPlayerTimer]: [number?, number?] = isTopPlayerWhite
39 | ? [room.gameState.timer?.whiteTimeLeft, room.gameState.timer?.blackTimeLeft]
40 | : [room.gameState.timer?.blackTimeLeft, room.gameState.timer?.whiteTimeLeft];
41 |
42 | return (
43 |
50 |
58 |
66 |
67 |
68 |
76 |
77 |
78 |
79 | );
80 | };
81 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/Board/Board.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useState} from "react";
2 | import * as ChessboardTypes from "react-chessboard/dist/chessboard/types";
3 | import {PromotionPieceOption} from "react-chessboard/dist/chessboard/types";
4 |
5 | import {Color, PieceSymbol, Square} from "@/GameClient/DataModel";
6 | import {ClientRoom, GameClient, PossibleMove} from "@/GameClient/GameClient";
7 | import {BoardWrapper} from "@/GameView/pages/GamePage/UI/Board/BoardWrapper";
8 |
9 | export interface BoardProps {
10 | room: ClientRoom;
11 | makingMove: boolean;
12 | gameClient: GameClient;
13 | }
14 |
15 | const MOVE_FROM_SQUARE_STYLE = {
16 | background: "rgba(255, 255, 0, 0.4)",
17 | };
18 |
19 | const OPTION_SQUARE_STYLE = {
20 | background: "radial-gradient(circle, rgba(0,0,0,.2) 25%, transparent 25%)",
21 | };
22 |
23 | const OPTION_BEAT_SQUARE_STYLE = {
24 | background: "radial-gradient(circle, rgba(201, 18, 9, 0.4) 70%, transparent 70%)",
25 | };
26 |
27 | const LAST_MOVE_SQUARE_STYLE = {
28 | background: "rgba(255, 180, 0, 0.4)",
29 | };
30 |
31 | const CHECK_SQUARE_STYLE = {
32 | background: "radial-gradient(circle, rgba(201, 18, 9, 1) 30%, transparent 75%)",
33 | };
34 |
35 | export const Board: FC = ({room, makingMove, gameClient}) => {
36 | const [moveFrom, setMoveFrom] = useState();
37 | const [possibleMoves, setPossibleMoves] = useState([]);
38 | const [promotionToSquare, setPromotionToSquare] = useState();
39 |
40 | const myColor = room.me.state.color;
41 | const turn = room.gameState.turn;
42 | const canMove: boolean = myColor === turn && !makingMove;
43 |
44 | const idDraggablePiece = ({piece}: {piece: ChessboardTypes.Piece}): boolean => {
45 | return canMove && piece.startsWith(myColor as string);
46 | };
47 |
48 | const highlightMoves = (square: ChessboardTypes.Square) => {
49 | const possibleMoves = gameClient.getPossibleMoves(square as Square);
50 | setMoveFrom(square as Square);
51 | setPossibleMoves(possibleMoves);
52 | };
53 |
54 | const clearHighlight = () => {
55 | setMoveFrom(undefined);
56 | setPossibleMoves([]);
57 | };
58 |
59 | const onSquareClick = (square: ChessboardTypes.Square): void => {
60 | if (!canMove) {
61 | return;
62 | }
63 |
64 | const possibleMove = possibleMoves.find((p) => p.to === square);
65 |
66 | if (possibleMove) {
67 | if (possibleMove.promotion) {
68 | setPromotionToSquare(square);
69 | } else {
70 | gameClient.makeMove({from: moveFrom! as Square, to: square as Square});
71 | clearHighlight();
72 | }
73 | } else {
74 | if (gameClient.getPieceColor(square as Square) !== room.me.state.color || square === moveFrom) {
75 | clearHighlight();
76 | } else {
77 | highlightMoves(square);
78 | }
79 | }
80 | };
81 |
82 | const onPieceDragBegin = (piece: ChessboardTypes.Piece, sourceSquare: Square) => {
83 | highlightMoves(sourceSquare);
84 | };
85 |
86 | const onPieceDrop = (sourceSquare: ChessboardTypes.Square, targetSquare: ChessboardTypes.Square): boolean => {
87 | const possibleMove = possibleMoves.find((p) => p.to === targetSquare);
88 | if (possibleMove && possibleMove.promotion) {
89 | setPromotionToSquare(targetSquare);
90 | return true;
91 | } else {
92 | clearHighlight();
93 | return gameClient.makeMove({from: moveFrom! as Square, to: targetSquare as Square});
94 | }
95 | };
96 |
97 | const onPromotionPieceSelect = (piece?: PromotionPieceOption) => {
98 | if (piece) {
99 | clearHighlight();
100 | const promotionPiece = piece[1].toLowerCase() as PieceSymbol;
101 | return gameClient.makeMove({
102 | from: moveFrom! as Square,
103 | to: promotionToSquare as Square,
104 | promotion: promotionPiece,
105 | });
106 | }
107 |
108 | setPromotionToSquare(undefined);
109 | return false;
110 | };
111 |
112 | const moveFromSquareStyle = {};
113 | if (moveFrom) {
114 | moveFromSquareStyle[moveFrom] = MOVE_FROM_SQUARE_STYLE;
115 | }
116 |
117 | const optionSquareStyles = {};
118 | for (const possibleMove of possibleMoves) {
119 | const color = gameClient.getPieceColor(possibleMove.to as Square);
120 |
121 | if (color && color === GameClient.swapColor(room.me.state.color!)) {
122 | optionSquareStyles[possibleMove.to] = OPTION_BEAT_SQUARE_STYLE;
123 | } else {
124 | optionSquareStyles[possibleMove.to] = OPTION_SQUARE_STYLE;
125 | }
126 | }
127 |
128 | const lastMoveSquareStyles = {};
129 | if (room.gameState.lastMove) {
130 | const lastMove = room.gameState.lastMove;
131 |
132 | lastMoveSquareStyles[lastMove.from] = LAST_MOVE_SQUARE_STYLE;
133 | lastMoveSquareStyles[lastMove.to] = LAST_MOVE_SQUARE_STYLE;
134 | }
135 |
136 | const checkSquareStyle = {};
137 | const checkKingSquare = gameClient.getCheck();
138 | if (checkKingSquare) {
139 | checkSquareStyle[checkKingSquare] = CHECK_SQUARE_STYLE;
140 | }
141 |
142 | return (
143 | false}
154 | customSquareStyles={{
155 | ...lastMoveSquareStyles,
156 | ...checkSquareStyle,
157 | ...moveFromSquareStyle,
158 | ...optionSquareStyles,
159 | }}
160 | />
161 | );
162 | };
163 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/Board/BoardWrapper.tsx:
--------------------------------------------------------------------------------
1 | import {ComponentProps, FC, useEffect, useRef, useState} from "react";
2 | import {Chessboard} from "react-chessboard";
3 |
4 | export const BoardWrapper: FC> = ({...props}) => {
5 | const wrapperRef = useRef(null);
6 |
7 | const [boardWidth, setBoardWidth] = useState(1);
8 |
9 | useEffect(() => {
10 | if (wrapperRef.current?.offsetHeight) {
11 | const resizeObserver = new ResizeObserver(() => {
12 | setBoardWidth(wrapperRef.current?.offsetHeight as number);
13 | });
14 | resizeObserver.observe(wrapperRef.current);
15 |
16 | return () => {
17 | resizeObserver.disconnect();
18 | };
19 | }
20 | }, []);
21 |
22 | return (
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/Board/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Board";
2 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/GameControlPanel/GameControlPanel.tsx:
--------------------------------------------------------------------------------
1 | import {Stack} from "@mui/material";
2 | import {FC, MouseEvent, useState} from "react";
3 |
4 | import {GameStatus, Member} from "@/GameClient/DataModel";
5 | import {ClientRoom} from "@/GameClient/GameClient";
6 | import {GiveUpPopup} from "@/GameView/pages/GamePage/UI/GameControlPanel/GiveUpPopup";
7 | import {PlayerActionsBlock} from "@/GameView/pages/GamePage/UI/GameControlPanel/PlayerActionsBlock";
8 | import {SpectatorsBlock} from "@/GameView/pages/GamePage/UI/GameControlPanel/SpectatorsBlock";
9 |
10 | export type GameControlPanelProps = {
11 | room: ClientRoom;
12 | onGiveUp: () => void;
13 | };
14 |
15 | const filterSpectators = (members: {[key: number]: Member}): Member[] => {
16 | return Object.values(members).filter((member) => !member.state.isPlayer);
17 | };
18 |
19 | const sortMembers = (members: Member[], currentUserId) => {
20 | return members.sort((a, b) => (b.user.id === currentUserId ? 1 : -1));
21 | };
22 |
23 | export const GameControlPanel: FC = ({room, onGiveUp}) => {
24 | const [giveUpOpen, setGiveUpOpen] = useState(false);
25 | const [giveUpAnchor, setGiveUpAnchor] = useState(null);
26 |
27 | const handleGiveUpClick = (event: MouseEvent) => {
28 | setGiveUpAnchor(event.currentTarget);
29 | setGiveUpOpen((previousOpen) => !previousOpen);
30 | };
31 |
32 | const handleGiveUpConfirm = () => {
33 | setGiveUpOpen(false);
34 | onGiveUp();
35 | };
36 |
37 | const handleGiveUpClose = () => {
38 | setGiveUpOpen(false);
39 | };
40 |
41 | const spectators = filterSpectators(room.members);
42 | const sortedSpectators = sortMembers(spectators, room.me.user.id);
43 | const isSpectatorsPresent = spectators.length > 0;
44 |
45 | const isPlayerActionsVisible = room.gameState.status === GameStatus.InProgress && room.me.state.isPlayer;
46 |
47 | if (!isSpectatorsPresent && !isPlayerActionsVisible) {
48 | return null;
49 | }
50 |
51 | return (
52 |
56 | {isPlayerActionsVisible ? : null}
57 | {isSpectatorsPresent ? : null}
58 |
59 |
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/GameControlPanel/GiveUpPopup.tsx:
--------------------------------------------------------------------------------
1 | import {Button, DialogActions, DialogContent, DialogTitle, Popover} from "@mui/material";
2 | import type {FC} from "react";
3 | import {useTranslation} from "react-i18next";
4 |
5 | export type GiveUpPopupProps = {
6 | open: boolean;
7 | anchorEl: HTMLElement | null;
8 | onCancel: () => void;
9 | onGiveUp: () => void;
10 | };
11 |
12 | export const GiveUpPopup: FC = ({open, anchorEl, onCancel, onGiveUp}) => {
13 | const {t} = useTranslation();
14 |
15 | return (
16 |
39 | {t("game.giveUpPopup.title")}
40 | {t("game.giveUpPopup.content")}
41 |
42 |
43 |
46 |
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/GameControlPanel/PlayerActionsBlock.tsx:
--------------------------------------------------------------------------------
1 | import EmojiFlagsRoundedIcon from "@mui/icons-material/EmojiFlagsRounded";
2 | import {Box, IconButton} from "@mui/material";
3 | import type {FC, MouseEvent} from "react";
4 |
5 | export type PlayerActionsBlockProps = {
6 | onGiveUp: (event: MouseEvent) => void;
7 | };
8 |
9 | export const PlayerActionsBlock: FC = ({onGiveUp}) => {
10 | return (
11 |
12 |
18 |
19 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/GameControlPanel/SpectatorsBlock.tsx:
--------------------------------------------------------------------------------
1 | import VisibilityIcon from "@mui/icons-material/Visibility";
2 | import {AvatarGroup, Stack} from "@mui/material";
3 | import type {FC} from "react";
4 |
5 | import {Member} from "@/GameClient/DataModel";
6 | import {UserAvatar} from "@/UI";
7 |
8 | export type SpectatorsBlockProps = {
9 | spectators: Member[];
10 | };
11 |
12 | export const SpectatorsBlock: FC = ({spectators}) => {
13 | return (
14 |
15 |
20 | {spectators.map((member) => (
21 |
22 | ))}
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/GameControlPanel/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./GameControlPanel";
2 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/GameResultPopup/GameResultPopup.tsx:
--------------------------------------------------------------------------------
1 | import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Slide} from "@mui/material";
2 | import {TransitionProps} from "@mui/material/transitions";
3 | import {FC, forwardRef, ReactElement, Ref} from "react";
4 | import {useTranslation} from "react-i18next";
5 |
6 | import {GameResolution, GameStatus, User} from "@/GameClient/DataModel";
7 | import {ClientRoom} from "@/GameClient/GameClient";
8 |
9 | export type GameResultPopupProps = {
10 | isOpen: boolean;
11 | room: ClientRoom;
12 | onClose: () => void;
13 | };
14 |
15 | const Transition = forwardRef(function Transition(
16 | props: TransitionProps & {
17 | children: ReactElement;
18 | },
19 | ref: Ref
20 | ) {
21 | return ;
22 | });
23 |
24 | export const GameResultPopup: FC = ({isOpen, room, onClose}) => {
25 | const {t} = useTranslation();
26 |
27 | if (room.gameState.status !== GameStatus.Finished) {
28 | return null;
29 | }
30 |
31 | const rematch = () => {
32 | window.Telegram.WebApp.switchInlineQuery(" ");
33 | };
34 |
35 | const resolution = room.gameState.resolution;
36 |
37 | let title: string;
38 | let explanation: string;
39 | const showRematchButton: boolean = room.me.state.isPlayer;
40 | if (room.me.state.isPlayer) {
41 | if (
42 | resolution === GameResolution.Checkmate ||
43 | resolution === GameResolution.OutOfTime ||
44 | resolution === GameResolution.PlayerQuit ||
45 | resolution === GameResolution.GiveUp
46 | ) {
47 | const resultKey = room.me.user.id === room.gameState.winner!.id ? "victory" : "defeat";
48 | title = t(`gameResult.${resultKey}.title`);
49 | explanation = t(`gameResult.${resultKey}.explanation.${resolution}`);
50 | } else {
51 | title = t("gameResult.draw.title");
52 | explanation = t(`gameResult.draw.explanation.${resolution}`);
53 | }
54 | } else {
55 | const winner: User | undefined = room.gameState.winner;
56 | let loser: User | undefined;
57 | if (winner) {
58 | if (winner.id === room.whitePlayer!.user.id) {
59 | loser = room.blackPlayer!.user;
60 | } else {
61 | loser = room.whitePlayer!.user;
62 | }
63 | }
64 |
65 | title = t("gameResult.spectator.title");
66 | explanation = t(`gameResult.spectator.explanation.${resolution}`, {
67 | winner: winner?.fullName,
68 | loser: loser?.fullName,
69 | });
70 | }
71 |
72 | return (
73 |
86 | );
87 | };
88 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/GameResultPopup/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./GameResultPopup";
2 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/PlayerBar/PlayerBar.tsx:
--------------------------------------------------------------------------------
1 | import {Box, Stack, Typography} from "@mui/material";
2 | import {FC, ReactNode, useEffect, useRef, useState} from "react";
3 |
4 | import {Member} from "@/GameClient/DataModel";
5 | import {PlayerTimer} from "@/GameView/pages/GamePage/UI/PlayerBar/PlayerTimer";
6 | import {UserAvatar} from "@/UI";
7 | import {ThinkingIndicator} from "@/UI";
8 |
9 | export type PlayerBarProps = {
10 | member: Member;
11 | turnIndicator: boolean;
12 | timeLeft?: number;
13 | timerGoing?: boolean;
14 | };
15 |
16 | export const PlayerBar: FC = ({member, turnIndicator, timeLeft, timerGoing}) => {
17 | const turnIndicatorBackground = turnIndicator ? {bgcolor: "background.paper"} : {bgcolor: "background.default"};
18 | let timerNode: ReactNode = null;
19 | if (timeLeft !== undefined && timerGoing !== undefined) {
20 | timerNode = ;
21 | } else {
22 | if (turnIndicator) {
23 | timerNode = ;
24 | }
25 | }
26 |
27 | const rootRef = useRef(null);
28 |
29 | const [avatarSize, setAvatarSize] = useState(1);
30 |
31 | useEffect(() => {
32 | if (rootRef.current?.offsetHeight) {
33 | const resizeObserver = new ResizeObserver((entries) => {
34 | setAvatarSize(entries[0].contentRect.height);
35 | });
36 | resizeObserver.observe(rootRef.current);
37 |
38 | return () => {
39 | resizeObserver.disconnect();
40 | };
41 | }
42 | }, []);
43 |
44 | return (
45 |
60 | ({
62 | position: "absolute",
63 | left: theme.spacing(1),
64 | top: theme.spacing(1),
65 | })}>
66 |
76 |
77 | {timerNode}
78 |
90 | {member.user.fullName}
91 |
92 |
93 | );
94 | };
95 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/UI/PlayerBar/PlayerTimer.tsx:
--------------------------------------------------------------------------------
1 | import {Typography} from "@mui/material";
2 | import {FC, useEffect, useRef, useState} from "react";
3 | import Countdown, {zeroPad} from "react-countdown";
4 |
5 | export type PlayerTimerProps = {
6 | timeLeft: number;
7 | going: boolean;
8 | turnIndicator: boolean;
9 | };
10 |
11 | export const PlayerTimer: FC = ({timeLeft, going, turnIndicator}) => {
12 | const countdownRef = useRef(null);
13 | const [date, setDate] = useState(Date.now() + timeLeft);
14 |
15 | useEffect(() => {
16 | if (going) {
17 | countdownRef.current!.start();
18 | } else {
19 | countdownRef.current!.stop();
20 | }
21 | }, [going]);
22 |
23 | useEffect(() => {
24 | setDate(Date.now() + timeLeft);
25 | }, [timeLeft]);
26 |
27 | return (
28 |
34 | (
41 |
42 | {zeroPad(minutes)}:{zeroPad(seconds)}.{milliseconds / 100}
43 |
44 | )}
45 | />
46 |
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/GamePage/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./GamePage";
2 | export * from "./UI/PlayerBar/PlayerBar";
3 | export * from "./UI/Board/Board";
4 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/WaitingPage/WaitingPage.tsx:
--------------------------------------------------------------------------------
1 | import {AvatarGroup, Divider, Stack, Typography} from "@mui/material";
2 | import React from "react";
3 | import {useTranslation} from "react-i18next";
4 |
5 | import {ClientRoom} from "@/GameClient/GameClient";
6 | import {UserAvatar} from "@/UI";
7 |
8 | export interface WaitingForUserProps {
9 | room: ClientRoom;
10 | }
11 |
12 | export const WaitingPage: React.FC = ({room}) => {
13 | const {t} = useTranslation();
14 |
15 | const isHost = room.hostID === room.me.user.id;
16 | const host = room.members[room.hostID];
17 |
18 | let avatars;
19 | let text;
20 |
21 | if (isHost) {
22 | text = t("game.waitingForAnyone");
23 | avatars = ;
24 | } else {
25 | text = t("game.waitingForUser", {user: host.user.fullName});
26 |
27 | const presentUsers = Object.values(room.members)
28 | .sort((a) => (a.user.id === room.me.user.id ? -1 : 1))
29 | .filter((member) => member.user.id !== room.hostID)
30 | .map((member) => (
31 |
32 | ));
33 |
34 | avatars = (
35 |
36 | {presentUsers}
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | return (
44 |
45 | {avatars}
46 |
47 | {text}
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/WaitingPage/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./WaitingPage";
2 |
--------------------------------------------------------------------------------
/client/src/GameView/pages/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ConnectingPage";
2 | export * from "./GamePage";
3 | export * from "./ErrorPage";
4 | export * from "./WaitingPage";
5 |
--------------------------------------------------------------------------------
/client/src/Telegram/TelegramThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import {createTheme, CssBaseline, Theme, ThemeProvider} from "@mui/material";
2 | import {ThemeOptions} from "@mui/material/styles/createTheme";
3 | import React, {FC, ReactNode, useEffect, useState} from "react";
4 |
5 | const generateTheme = (): Theme => {
6 | const telegramTheme = {
7 | colorScheme: window.Telegram.WebApp.colorScheme,
8 | bg_color: window.Telegram.WebApp.themeParams.bg_color,
9 | text_color: window.Telegram.WebApp.themeParams.text_color,
10 | hint_color: window.Telegram.WebApp.themeParams.hint_color,
11 | link_color: window.Telegram.WebApp.themeParams.link_color,
12 | button_color: window.Telegram.WebApp.themeParams.button_color,
13 | button_text_color: window.Telegram.WebApp.themeParams.button_text_color,
14 | secondary_bg_color: window.Telegram.WebApp.themeParams.secondary_bg_color,
15 | };
16 |
17 | const themeOptions: ThemeOptions = {
18 | palette: {
19 | mode: telegramTheme.colorScheme,
20 | },
21 | components: {
22 | MuiSwitch: {
23 | defaultProps: {
24 | disableRipple: true,
25 | },
26 | styleOverrides: {
27 | root: ({ownerState, theme}) => ({
28 | width: 52,
29 | }),
30 | switchBase: ({ownerState, theme}) => ({
31 | padding: "10px",
32 | "&:hover": {
33 | background: "none",
34 | },
35 | "&.Mui-checked": {
36 | transform: "translateX(13px)",
37 | color: theme.palette.primary.main,
38 | "&:hover": {
39 | background: "none",
40 | },
41 | "& + .MuiSwitch-track": {
42 | opacity: 1,
43 | },
44 | "& .MuiSwitch-thumb": {
45 | borderColor: theme.palette.primary.main,
46 | },
47 | },
48 | }),
49 | thumb: ({ownerState, theme}) => ({
50 | background: theme.palette.background.default,
51 | border: "solid 2px",
52 | borderColor: theme.palette.text.disabled,
53 | opacity: 1,
54 | boxShadow: "none",
55 | transition: "border-color .3s ease-in-out",
56 | width: 18,
57 | height: 18,
58 |
59 | "&.Mui-checked": {
60 | borderColor: theme.palette.primary.main,
61 | },
62 | }),
63 | track: ({ownerState, theme}) => ({
64 | background: theme.palette.text.disabled,
65 | transition: "background-color .3s ease-in-out",
66 | opacity: 1,
67 | }),
68 | },
69 | },
70 | MuiSlider: {
71 | styleOverrides: {
72 | rail: ({ownerState, theme}) => ({
73 | height: 3,
74 | background: theme.palette.text.disabled,
75 | opacity: 1,
76 | }),
77 | track: ({ownerState, theme}) => ({
78 | height: 3,
79 | border: 0,
80 | background: theme.palette.primary.main,
81 | }),
82 | thumb: {
83 | width: 15,
84 | height: 15,
85 | },
86 | },
87 | },
88 | },
89 | };
90 |
91 | if (telegramTheme.button_color && telegramTheme.button_text_color) {
92 | themeOptions.palette!.primary = {
93 | main: telegramTheme.button_color,
94 | contrastText: telegramTheme.button_text_color,
95 | };
96 | themeOptions.palette!.secondary = {
97 | main: telegramTheme.button_color,
98 | contrastText: telegramTheme.button_text_color,
99 | };
100 | }
101 |
102 | if (telegramTheme.bg_color && telegramTheme.secondary_bg_color) {
103 | themeOptions.palette!.background = {
104 | default: telegramTheme.bg_color,
105 | paper: telegramTheme.secondary_bg_color,
106 | };
107 | }
108 |
109 | if (telegramTheme.text_color) {
110 | themeOptions.palette!.text = {
111 | primary: telegramTheme.text_color,
112 | secondary: telegramTheme.text_color,
113 | };
114 |
115 | if (telegramTheme.hint_color) {
116 | themeOptions.palette!.text.disabled = telegramTheme.hint_color;
117 | }
118 | }
119 |
120 | return createTheme(themeOptions);
121 | };
122 |
123 | type ThemeProviderProps = {
124 | children?: ReactNode;
125 | };
126 |
127 | export const TelegramThemeProvider: FC = ({children}) => {
128 | const [theme, setTheme] = useState(() => generateTheme());
129 |
130 | useEffect(() => {
131 | const handleThemeChange = () => {
132 | setTheme(generateTheme());
133 | };
134 |
135 | window.Telegram.WebApp.onEvent("themeChanged", handleThemeChange);
136 |
137 | return () => {
138 | window.Telegram.WebApp.offEvent("themeChanged", handleThemeChange);
139 | };
140 | }, []);
141 |
142 | return (
143 |
144 |
145 | {children}
146 |
147 | );
148 | };
149 |
--------------------------------------------------------------------------------
/client/src/Telegram/Types.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | Telegram: Telegram;
4 | }
5 | }
6 |
7 | export interface Telegram {
8 | WebApp: WebApp;
9 | }
10 |
11 | interface MiniAppsEvents {
12 | themeChanged: () => void;
13 | }
14 |
15 | interface WebApp {
16 | /**
17 | * A string with raw data transferred to the Mini App, convenient for validating data.
18 | *
19 | * WARNING: Validate data from this field before using it on the bot's server.
20 | */
21 | initData: string;
22 |
23 | /**
24 | * An object with input data transferred to the Mini App.
25 | *
26 | * WARNING: Data from this field should not be trusted. You should only use data from initData on the bot's
27 | * server and only after it has been validated.
28 | */
29 | initDataUnsafe: WebAppInitData;
30 |
31 | /**
32 | * The version of the Bot API available in the user's Telegram app.
33 | */
34 | version: string;
35 |
36 | /**
37 | * The name of the platform of the user's Telegram app.
38 | */
39 | platform: string;
40 |
41 | /**
42 | * The color scheme currently used in the Telegram app. Either “light” or “dark”.
43 | *
44 | * Also available as the CSS variable var(--tg-color-scheme).
45 | */
46 | colorScheme: "light" | "dark";
47 |
48 | /**
49 | * An object containing the current theme settings used in the Telegram app.
50 | */
51 | themeParams: ThemeParams;
52 |
53 | /**
54 | * True, if the Mini App is expanded to the maximum available height. False, if the Mini App occupies part of the
55 | * screen and can be expanded to the full height using the {@link expand()} method.
56 | */
57 | isExpanded: boolean;
58 |
59 | /**
60 | * The current height of the visible area of the Mini App. Also available in CSS as the variable
61 | * var(--tg-viewport-height).
62 | *
63 | *
64 | * The application can display just the top part of the Mini App, with its lower part remaining outside the screen
65 | * area. From this position, the user can “pull” the Mini App to its maximum height, while the bot can do the same
66 | * by calling the {@link expand()} method. As the position of the Mini App changes, the current height value of the
67 | * visible area will be updated in real time.
68 | *
69 | *
70 | * Please note that the refresh rate of this value is not sufficient to smoothly follow the lower border of the
71 | * window. It should not be used to pin interface elements to the bottom of the visible area. It's more appropriate
72 | * to use the value of the viewportStableHeight field for this purpose.
73 | */
74 | viewportHeight: number;
75 |
76 | /**
77 | * The height of the visible area of the Mini App in its last stable state. Also available in CSS as a variable
78 | * var(--tg-viewport-stable-height).
79 | *
80 | *
81 | * The application can display just the top part of the Mini App, with its lower part remaining outside the screen
82 | * area. From this position, the user can “pull” the Mini App to its maximum height, while the bot can do the same
83 | * by calling the {@link expand()} method. Unlike the value of viewportHeight, the value of viewportStableHeight
84 | * does not change as the position of the Mini App changes with user gestures or during animations. The value of
85 | * viewportStableHeight will be updated after all gestures and animations are completed and the Mini App reaches its
86 | * final size.
87 | *
88 | *
89 | * Note the event viewportChanged with the passed parameter isStateStable=true, which will allow you to track
90 | * when the stable state of the height of the visible area changes.
91 | */
92 | viewportStableHeight: number;
93 |
94 | /**
95 | * Current header color in the #RRGGBB format.
96 | */
97 | headerColor: string;
98 |
99 | /**
100 | * Current background color in the #RRGGBB format.
101 | */
102 | backgroundColor: string;
103 |
104 | /**
105 | * True, if the confirmation dialog is enabled while the user is trying to close the Mini App. False,
106 | * if the confirmation dialog is disabled.
107 | */
108 | isClosingConfirmationEnabled: boolean;
109 |
110 | /**
111 | * An object for controlling the back button which can be displayed in the header of the Mini App in the Telegram
112 | * interface.
113 | */
114 | BackButton: BackButton;
115 |
116 | /**
117 | * An object for controlling the main button, which is displayed at the bottom of the Mini App in the Telegram
118 | * interface.
119 | */
120 | MainButton: MainButton;
121 |
122 | //...
123 |
124 | /**
125 | * A method that sets the app event handler. Check the list of available events.
126 | */
127 | onEvent(
128 | eventType: EventType,
129 | eventHandler: MiniAppsEvents[EventType]
130 | ): void;
131 |
132 | /**
133 | * A method that deletes a previously set event handler.
134 | */
135 | offEvent(
136 | eventType: EventType,
137 | eventHandler: MiniAppsEvents[EventType]
138 | ): void;
139 |
140 | //...
141 |
142 | /**
143 | * Bot API 6.7+ A method that inserts the bot's username and the specified inline query in the current chat's input
144 | * field. Query may be empty, in which case only the bot's username will be inserted. If an optional
145 | * choose_chat_types parameter was passed, the client prompts the user to choose a specific chat, then opens that
146 | * chat and inserts the bot's username and the specified inline query in the input field. You can specify which
147 | * types of chats the user will be able to choose from. It can be one or more of the following types: users, bots,
148 | * groups, channels.
149 | */
150 | switchInlineQuery(query: string, choose_chat_types?: string): void;
151 |
152 | //...
153 |
154 | /**
155 | * A method that expands the Mini App to the maximum available height. To find out if the Mini App is expanded to
156 | * the maximum height, refer to the value of the {@link Telegram.WebApp.isExpanded} parameter
157 | */
158 | expand: () => void;
159 | }
160 |
161 | export interface ThemeParams {
162 | /**
163 | * Background color in the #RRGGBB format.
164 | *
165 | * Also available as the CSS variable var(--tg-theme-bg-color).
166 | */
167 | bg_color?: string;
168 |
169 | /**
170 | * Main text color in the #RRGGBB format.
171 | *
172 | * Also available as the CSS variable var(--tg-theme-text-color).
173 | */
174 | text_color?: string;
175 |
176 | /**
177 | * Hint text color in the #RRGGBB format.
178 | *
179 | * Also available as the CSS variable var(--tg-theme-hint-color).
180 | */
181 | hint_color?: string;
182 |
183 | /**
184 | * Link color in the #RRGGBB format.
185 | *
186 | * Also available as the CSS variable var(--tg-theme-link-color).
187 | */
188 | link_color?: string;
189 |
190 | /**
191 | * Button color in the #RRGGBB format.
192 | *
193 | * Also available as the CSS variable var(--tg-theme-button-color).
194 | */
195 | button_color?: string;
196 |
197 | /**
198 | * Button text color in the #RRGGBB format.
199 | *
200 | * Also available as the CSS variable var(--tg-theme-button-text-color).
201 | */
202 | button_text_color?: string;
203 |
204 | /**
205 | * Bot API 6.1+ Secondary background color in the #RRGGBB format.
206 | *
207 | * Also available as the CSS variable var(--tg-theme-secondary-bg-color).
208 | */
209 | secondary_bg_color?: string;
210 | }
211 |
212 | /**
213 | * This object controls the back button, which can be displayed in the header of the Mini App in the Telegram interface.
214 | */
215 | export interface BackButton {
216 | /**
217 | * Shows whether the button is visible. Set to false by default.
218 | */
219 | isVisible: boolean;
220 |
221 | /**
222 | * Bot API 6.1+ A method that sets the button press event handler. An alias for
223 | * Telegram.WebApp.onEvent('backButtonClicked', callback)
224 | */
225 | onClick(callback: () => void): void;
226 |
227 | /**
228 | * Bot API 6.1+ A method that removes the button press event handler. An alias for
229 | * Telegram.WebApp.offEvent('backButtonClicked', callback)
230 | */
231 | offClick(callback: () => void): void;
232 |
233 | /**
234 | * Bot API 6.1+ A method to make the button active and visible.
235 | */
236 | show(): void;
237 |
238 | /**
239 | * Bot API 6.1+ A method to hide the button.
240 | */
241 | hide(): void;
242 | }
243 |
244 | export interface MainButton {
245 | /**
246 | * Current button text. Set to CONTINUE by default.
247 | */
248 | text: string;
249 |
250 | /**
251 | * Current button color. Set to themeParams.button_color by default.
252 | */
253 | color: string;
254 |
255 | /**
256 | * Current button text color. Set to themeParams.button_text_color by default.
257 | */
258 | textColor: string;
259 |
260 | /**
261 | * Shows whether the button is visible. Set to false by default.
262 | */
263 | isVisible: boolean;
264 |
265 | /**
266 | * Shows whether the button is active. Set to true by default.
267 | */
268 | isActive: boolean;
269 |
270 | /**
271 | * Readonly. Shows whether the button is displaying a loading indicator.
272 | */
273 | isProgressVisible: boolean;
274 |
275 | /**
276 | * A method to set the button text.
277 | */
278 | setText(text: string): void;
279 |
280 | /**
281 | * A method that sets the button press event handler. An alias for
282 | * Telegram.WebApp.onEvent('mainButtonClicked', callback)
283 | */
284 | onClick(callback: () => void): void;
285 |
286 | /**
287 | * A method that removes the button press event handler. An alias for
288 | * Telegram.WebApp.offEvent('mainButtonClicked', callback)
289 | */
290 | offClick(callback: () => void): void;
291 |
292 | /**
293 | * A method to make the button visible.
294 | * Note that opening the Mini App from the attachment menu hides the main button until the user interacts with the
295 | * Mini App interface.
296 | */
297 | show(): void;
298 |
299 | /**
300 | * A method to hide the button.
301 | */
302 | hide(): void;
303 |
304 | /**
305 | * A method to enable the button.
306 | */
307 | enable(): void;
308 |
309 | /**
310 | * A method to disable the button.
311 | */
312 | disable(): void;
313 |
314 | /**
315 | * A method to show a loading indicator on the button.
316 | * It is recommended to display loading progress if the action tied to the button may take a long time. By default,
317 | * the button is disabled while the action is in progress. If the parameter leaveActive=true is passed, the button
318 | * remains enabled.
319 | */
320 | showProgress(leaveActive: boolean): void;
321 |
322 | /**
323 | * A method to hide the loading indicator.
324 | */
325 | hideProgress(): void;
326 |
327 | /**
328 | * A method to set the button parameters. The params parameter is an object containing one or several fields that
329 | * need to be changed:
330 | * text - button text;
331 | * color - button color;
332 | * text_color - button text color;
333 | * is_active - enable the button;
334 | * is_visible - show the button.
335 | */
336 | setParams(params: {
337 | text?: string;
338 | color?: string;
339 | text_color?: string;
340 | is_active?: boolean;
341 | is_visible?: boolean;
342 | }): void;
343 | }
344 |
345 | export interface WebAppUser {
346 | id: number;
347 | is_bot?: boolean;
348 | first_name: string;
349 | last_name?: string;
350 | username?: string;
351 | language_code?: string;
352 | is_premium?: true;
353 | added_to_attachment_menu?: true;
354 | allows_write_to_pm?: true;
355 | photo_url?: string;
356 | }
357 |
358 | export interface WebAppChat {
359 | id: number;
360 | type: string;
361 | title: string;
362 | username?: string;
363 | photo_url?: string;
364 | }
365 |
366 | export interface WebAppInitData {
367 | query_id?: string;
368 | user?: WebAppUser;
369 | receiver?: WebAppUser;
370 | chat?: WebAppChat;
371 | chat_type?: string;
372 | chat_instance?: string;
373 | start_param?: string;
374 | can_send_after?: number;
375 | auth_date: number;
376 | hash: string;
377 | }
378 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/ColorPicker/ColorPicker.tsx:
--------------------------------------------------------------------------------
1 | import {Button, FormControlLabel, Stack, styled} from "@mui/material";
2 | import {FC} from "react";
3 | import {useTranslation} from "react-i18next";
4 |
5 | import {ReactComponent as KingOutlined} from "@/UI/Telegram/ColorPicker/assets/king-outlined.svg";
6 | import {ReactComponent as QuestionMark} from "@/UI/Telegram/ColorPicker/assets/question-mark.svg";
7 |
8 | export type ColorPickerValueType = "white" | "black" | "random";
9 |
10 | export type ColorPickerProps = {
11 | value: ColorPickerValueType;
12 | onChange: (value: ColorPickerValueType) => void;
13 | };
14 |
15 | type ColorPickerRadioProps = {
16 | value: ColorPickerValueType;
17 | label: string;
18 | selected: boolean;
19 | onClick: (value: ColorPickerValueType) => void;
20 | };
21 |
22 | type ColorPickerBoxProps = {
23 | value: ColorPickerValueType;
24 | };
25 |
26 | const ColorPickerBox = styled(Button)(({theme, value}) => {
27 | const before = {
28 | content: '""',
29 | position: "absolute",
30 | left: 0,
31 | right: 0,
32 | top: 0,
33 | bottom: 0,
34 | clipPath: "polygon(0 100%, 100% 0, 100% 100%)",
35 | background: "#eee",
36 | zIndex: 0,
37 | };
38 |
39 | return {
40 | padding: theme.spacing(3),
41 | borderRadius: theme.shape.borderRadius,
42 | background: value === "white" ? "#eee" : "#222",
43 | flexGrow: 1,
44 | cursor: "pointer",
45 | alignSelf: "stretch",
46 | display: "flex",
47 | alignItems: "center",
48 | gap: theme.spacing(1),
49 | flexDirection: "column",
50 | color: value === "white" ? "#111" : "#eee",
51 | position: "relative",
52 | overflow: "hidden",
53 | transition: "box-shadow .2s ease-in-out",
54 |
55 | "&:hover": {
56 | background: value === "white" ? "#eee" : "#222",
57 | },
58 |
59 | "& .MuiTouchRipple-root": {
60 | color: value === "random" ? "#888" : undefined,
61 | },
62 |
63 | "&:before": value === "random" ? before : undefined,
64 | };
65 | });
66 |
67 | const ColorPickerRadio: FC = ({value, label, selected, onClick}) => {
68 | const icon =
69 | value === "random" ? : ;
70 |
71 | const selectedSx = (theme) => ({
72 | boxShadow: `0 0 0 4px ${theme.palette.primary.main}`,
73 | });
74 |
75 | return (
76 | onClick(value)}>
88 | {icon}
89 |
90 | }
91 | label={label}
92 | />
93 | );
94 | };
95 |
96 | export const ColorPicker: FC = ({value, onChange}) => {
97 | const {t} = useTranslation();
98 |
99 | return (
100 |
101 |
107 |
113 |
119 |
120 | );
121 | };
122 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/ColorPicker/assets/king-outlined.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/ColorPicker/assets/question-mark.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/ColorPicker/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ColorPicker";
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/Menu/Menu.tsx:
--------------------------------------------------------------------------------
1 | import {List, styled} from "@mui/material";
2 |
3 | export const Menu = styled(List)(({theme}) => ({
4 | background: theme.palette.background.default,
5 | }));
6 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/Menu/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Menu";
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuDivider/MenuDivider.tsx:
--------------------------------------------------------------------------------
1 | import {Divider, styled} from "@mui/material";
2 |
3 | export const MenuDivider = styled(Divider)(({theme}) => ({
4 | background: theme.palette.background.paper,
5 | height: 5,
6 | border: 0,
7 | }));
8 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuDivider/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./MenuDivider";
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuItem/MenuItem.tsx:
--------------------------------------------------------------------------------
1 | import {ListItem, ListItemButton, ListItemIcon, ListItemText, Stack} from "@mui/material";
2 | import {FC, ReactNode} from "react";
3 |
4 | export type MenuItemProps = {
5 | icon?: ReactNode;
6 | primary?: ReactNode;
7 | secondary?: ReactNode;
8 | onClick?: () => void;
9 | children?: ReactNode;
10 | direction?: "row" | "column";
11 | };
12 |
13 | export const MenuItem: FC = ({icon, primary, secondary, onClick, children, direction = "row"}) => {
14 | const RootComponent = onClick ? ListItemButton : ListItem;
15 |
16 | return (
17 |
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuItem/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./MenuItem";
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuItemSlider/MenuItemSlider.tsx:
--------------------------------------------------------------------------------
1 | import {Slider, SliderProps, Stack, Typography} from "@mui/material";
2 | import {FC} from "react";
3 |
4 | import {MenuItem, MenuItemProps} from "@/UI/Telegram";
5 |
6 | export type MenuItemSliderProps = {
7 | tooltip?: string;
8 | sliderProps?: SliderProps;
9 | } & Omit;
10 |
11 | export const MenuItemSlider: FC = ({icon, tooltip, sliderProps, ...props}) => {
12 | return (
13 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuItemSlider/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./MenuItemSlider";
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuItemSwitch/MenuItemSwitch.tsx:
--------------------------------------------------------------------------------
1 | import {Switch} from "@mui/material";
2 | import {FC} from "react";
3 |
4 | import {MenuItem, MenuItemProps} from "@/UI/Telegram";
5 |
6 | export type MenuItemSwitchProps = {
7 | onChange: (value: boolean) => void;
8 | value: boolean;
9 | } & Omit;
10 |
11 | export const MenuItemSwitch: FC = ({onChange, value, ...props}) => {
12 | return (
13 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/MenuItemSwitch/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./MenuItemSwitch";
2 |
--------------------------------------------------------------------------------
/client/src/UI/Telegram/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ColorPicker";
2 | export * from "./Menu";
3 | export * from "./MenuDivider";
4 | export * from "./MenuItem";
5 | export * from "./MenuItemSwitch";
6 | export * from "./MenuItemSlider";
7 |
--------------------------------------------------------------------------------
/client/src/UI/ThinkingIndicator/ThinkingIndicator.tsx:
--------------------------------------------------------------------------------
1 | import {Box} from "@mui/material";
2 | import {FC} from "react";
3 |
4 | export const ThinkingIndicator: FC = () => {
5 | return (
6 |
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/client/src/UI/ThinkingIndicator/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ThinkingIndicator";
2 |
--------------------------------------------------------------------------------
/client/src/UI/UserAvatar/UserAvatar.tsx:
--------------------------------------------------------------------------------
1 | import {Avatar, Badge, styled, SxProps, Theme} from "@mui/material";
2 | import React from "react";
3 | import tinycolor from "tinycolor2";
4 |
5 | import {User} from "@/GameClient/DataModel";
6 |
7 | function stringToColor(string: string) {
8 | let hash = 0;
9 | let i;
10 |
11 | /* eslint-disable no-bitwise */
12 | for (i = 0; i < string.length; i += 1) {
13 | hash = string.charCodeAt(i) + ((hash << 5) - hash);
14 | }
15 |
16 | let color = "#";
17 |
18 | for (i = 0; i < 3; i += 1) {
19 | const value = (hash >> (i * 8)) & 0xff;
20 | color += `00${value.toString(16)}`.slice(-2);
21 | }
22 | /* eslint-enable no-bitwise */
23 |
24 | return color;
25 | }
26 |
27 | function stringAvatar(name: string, sx?: SxProps) {
28 | const hue = tinycolor(stringToColor(name)).toHsl().h;
29 |
30 | const top: string = `hsl(${hue}, 90%, 70%)`;
31 | const bottom: string = `hsl(${hue}, 45%, 45%)`;
32 | const gradient: string = `linear-gradient(20deg, ${bottom} 0%, ${top} 100%);`;
33 |
34 | const letters = name
35 | .split(" ")
36 | .map((s) => s[0])
37 | .slice(0, 2)
38 | .join("");
39 |
40 | return {
41 | sx: [
42 | {background: gradient, color: "white", fontSize: 15, paddingTop: "2px"},
43 | ...(Array.isArray(sx) ? sx : [sx]),
44 | ],
45 | children: letters.toUpperCase(),
46 | };
47 | }
48 |
49 | type OnlineBadgeProps = {
50 | paperBackground?: boolean;
51 | };
52 |
53 | const OnlineBadge = styled(Badge, {
54 | shouldForwardProp: (prop) => prop !== "paperBackground",
55 | })(({theme, paperBackground}) => ({
56 | "& .MuiBadge-badge": {
57 | backgroundColor: theme.palette.primary.main,
58 | color: theme.palette.primary.main,
59 | boxShadow: `0 0 0 2px ${paperBackground ? theme.palette.background.paper : theme.palette.background.default}`,
60 | "&::after": {
61 | position: "absolute",
62 | top: 0,
63 | left: 0,
64 | width: "100%",
65 | height: "100%",
66 | borderRadius: "50%",
67 | animation: "ripple 1.2s infinite ease-in-out",
68 | border: "1px solid currentColor",
69 | content: '""',
70 | },
71 | },
72 | "@keyframes ripple": {
73 | "0%": {
74 | transform: "scale(.8)",
75 | opacity: 1,
76 | },
77 | "100%": {
78 | transform: "scale(2.4)",
79 | opacity: 0,
80 | },
81 | },
82 | }));
83 |
84 | export interface PlayerAvatarProps {
85 | user: User;
86 | sx?: SxProps;
87 | online?: boolean;
88 | paperBackground?: boolean;
89 | }
90 |
91 | export const UserAvatar: React.FC = ({user, sx, online, paperBackground}) => {
92 | let avatar: JSX.Element;
93 | if (user.avatarURL) {
94 | avatar = ;
95 | } else {
96 | avatar = ;
97 | }
98 |
99 | if (online) {
100 | return (
101 |
106 | {avatar}
107 |
108 | );
109 | } else {
110 | return avatar;
111 | }
112 | };
113 |
--------------------------------------------------------------------------------
/client/src/UI/UserAvatar/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./UserAvatar";
2 |
--------------------------------------------------------------------------------
/client/src/UI/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./UserAvatar";
2 | export * from "./ThinkingIndicator";
3 |
--------------------------------------------------------------------------------
/client/src/assets/audio/move.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Quatern1on/ChessNowBot/58461fb15d689e2841ac4d4b4275c06a4ac9801a/client/src/assets/audio/move.wav
--------------------------------------------------------------------------------
/client/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from "i18next";
2 | import {initReactI18next} from "react-i18next";
3 |
4 | const urlLang = new URLSearchParams(window.location.search).get("lang");
5 | const webAppLang = window.Telegram.WebApp.initDataUnsafe.user?.language_code;
6 |
7 | i18n.use(initReactI18next).init({
8 | resources: {
9 | en: {
10 | translation: {
11 | settings: {
12 | timer: "Enable timer",
13 | initTime: "Minutes per player",
14 | increment: "Increment in seconds",
15 | color: "Your color",
16 | colors: {
17 | black: "Black",
18 | random: "Random",
19 | white: "White",
20 | },
21 | done: "Create",
22 | },
23 | game: {
24 | connecting: "Connecting",
25 | waitingForUser: "Waiting for {{user}} to connect",
26 | waitingForAnyone: "Waiting for anyone to join",
27 | you: "You",
28 | giveUpPopup: {
29 | title: "Give up",
30 | content: "Are you sure you want to give up?",
31 | cancel: "Cancel",
32 | confirm: "Give up",
33 | },
34 | },
35 | error: {
36 | title: "Oops! Something went wrong.",
37 | message: "We apologize for the inconvenience. There was an error processing your request.",
38 | possibleSolutions: "Possible solutions:",
39 | checkInternet: "Check your internet connection.",
40 | refreshPage: "Try refreshing the page.",
41 | contactSupport: "Contact our support team for further assistance.",
42 | },
43 | gameResult: {
44 | rematch: "Rematch",
45 | victory: {
46 | title: "Victory",
47 | explanation: {
48 | checkmate: "You checkmated your opponent",
49 | "out-of-time": "Your opponent ran out of time",
50 | "player-quit": "Your opponent quit the game",
51 | "give-up": "Your opponent gave up",
52 | },
53 | },
54 | defeat: {
55 | title: "Defeat",
56 | explanation: {
57 | checkmate: "Your opponent checkmated you",
58 | "out-of-time": "You ran out of time",
59 | "player-quit": "You quit the game",
60 | "give-up": "You gave up",
61 | },
62 | },
63 | draw: {
64 | title: "Draw",
65 | explanation: {
66 | stalemate: "Stalemate",
67 | draw: "Technical draw",
68 | },
69 | },
70 | spectator: {
71 | title: "Game Over",
72 | explanation: {
73 | checkmate: "{{winner}} checkmated {{loser}}",
74 | "out-of-time": "{{loser}}'s timer ran out",
75 | "player-quit": "{{loser}} left the game",
76 | "give-up": "{{loser}} gave up",
77 | stalemate: "Stalemate",
78 | draw: "Technical Draw",
79 | },
80 | },
81 | },
82 | },
83 | },
84 | ru: {
85 | translation: {
86 | settings: {
87 | timer: "Использовать таймер",
88 | initTime: "Минут на игрока",
89 | increment: "Бонусное время в секундах",
90 | color: "Ваш цвет",
91 | colors: {
92 | black: "Чёрный",
93 | random: "Случайно",
94 | white: "Белый",
95 | },
96 | done: "Создать",
97 | },
98 | game: {
99 | connecting: "Подключение",
100 | waitingForUser: "Ожидание подключения пользователя {{user}}",
101 | waitingForAnyone: "Ожидание пока кто-нибудь присоеденится",
102 | you: "Вы",
103 | giveUpPopup: {
104 | title: "Сдаться",
105 | content: "Вы уверены, что хотите сдаться?",
106 | cancel: "Отмена",
107 | confirm: "Сдаться",
108 | },
109 | },
110 | error: {
111 | title: "Упс! Что-то пошло не так.",
112 | message: "Приносим извинения за неудобства. Произошла ошибка при обработке вашего запроса.",
113 | possibleSolutions: "Возможные решения:",
114 | checkInternet: "Проверьте подключение к интернету.",
115 | refreshPage: "Попробуйте обновить страницу.",
116 | contactSupport: "Свяжитесь с нашей службой поддержки для получения дополнительной помощи.",
117 | },
118 | gameResult: {
119 | rematch: "Реванш",
120 | victory: {
121 | title: "Победа",
122 | explanation: {
123 | checkmate: "Вы поставили мат вашему противнику",
124 | "out-of-time": "У вашего противника закончилось время",
125 | "player-quit": "Ваш противник покинул игру",
126 | "give-up": "Ваш противник сдался",
127 | },
128 | },
129 | defeat: {
130 | title: "Поражение",
131 | explanation: {
132 | checkmate: "Противник поставил вам мат",
133 | "out-of-time": "У вас закончилось время",
134 | "player-quit": "Вы покинули игру",
135 | "give-up": "Вы сдались",
136 | },
137 | },
138 | draw: {
139 | title: "Ничья",
140 | explanation: {
141 | stalemate: "Пат",
142 | draw: "Техническая ничья",
143 | },
144 | },
145 | spectator: {
146 | title: "Игра завершена",
147 | explanation: {
148 | checkmate: "{{winner}} поставили мат {{loser}}",
149 | "out-of-time": "Таймер {{loser}} опустился до нуля",
150 | "player-quit": "{{loser}} покинул игру",
151 | "give-up": "{{loser}} сдался",
152 | stalemate: "Пат",
153 | draw: "Техническая ничья",
154 | },
155 | },
156 | },
157 | },
158 | },
159 | uk: {
160 | translation: {
161 | settings: {
162 | timer: "Використовувати таймер",
163 | initTime: "Хвилин на гравця",
164 | increment: "Бонусний час у секундах",
165 | color: "Ваш колір",
166 | colors: {
167 | black: "Чорний",
168 | random: "Випадковий",
169 | white: "Білий",
170 | },
171 | done: "Створити",
172 | },
173 | game: {
174 | connecting: "Підключення",
175 | waitingForUser: "Очікування підключення користувача {{user}}",
176 | waitingForAnyone: "Очікування, доки хтось приєднається",
177 | you: "Ви",
178 | giveUpPopup: {
179 | title: "Здатися",
180 | content: "Ви впевнені, що хочете здатися?",
181 | cancel: "Скасувати",
182 | confirm: "Здатися",
183 | },
184 | },
185 | error: {
186 | title: "Ой! Щось пішло не так.",
187 | message: "Вибачте за незручності. Виникла помилка при обробці вашого запиту.",
188 | possibleSolutions: "Можливі рішення:",
189 | checkInternet: "Перевірте підключення до Інтернету.",
190 | refreshPage: "Спробуйте оновити сторінку.",
191 | contactSupport: "Зверніться до нашої служби підтримки для отримання додаткової допомоги.",
192 | },
193 | gameResult: {
194 | rematch: "Реванш",
195 | victory: {
196 | title: "Перемога",
197 | explanation: {
198 | checkmate: "Ви поставили мат вашому супротивнику",
199 | "out-of-time": "У вашого супротивника закінчився час",
200 | "player-quit": "Ваш супротивник покинув гру",
201 | "give-up": "Ваш супротивник здався",
202 | },
203 | },
204 | defeat: {
205 | title: "Поразка",
206 | explanation: {
207 | checkmate: "Супротивник поставив вам мат",
208 | "out-of-time": "У вас закінчився час",
209 | "player-quit": "Ви покинули гру",
210 | "give-up": "Ви здались",
211 | },
212 | },
213 | draw: {
214 | title: "Нічия",
215 | explanation: {
216 | stalemate: "Пат",
217 | draw: "Технічна нічия",
218 | },
219 | },
220 | spectator: {
221 | title: "Гра завершена",
222 | explanation: {
223 | checkmate: "{{winner}} поставили мат {{loser}}",
224 | "out-of-time": "Таймер {{loser}} опустився до нуля",
225 | "player-quit": "{{loser}} покинув гру",
226 | "give-up": "{{loser}} здався",
227 | stalemate: "Пат",
228 | draw: "Технічна нічия",
229 | },
230 | },
231 | },
232 | },
233 | },
234 | },
235 | lng: urlLang || webAppLang || "en",
236 | fallbackLng: "en",
237 |
238 | interpolation: {
239 | escapeValue: false,
240 | },
241 | });
242 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | html {
2 | height: 100%;
3 | width: 100%;
4 | overflow: hidden;
5 | }
6 |
7 | body, #root {
8 | height: var(--tg-viewport-stable-height);
9 | transition: height .3s ease-in-out;
10 | overflow: visible;
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import "./index.css";
2 | import "@/i18n";
3 | import "@fontsource/roboto/300.css";
4 | import "@fontsource/roboto/400.css";
5 | import "@fontsource/roboto/500.css";
6 | import "@fontsource/roboto/700.css";
7 |
8 | import React from "react";
9 | import ReactDOM from "react-dom/client";
10 |
11 | import {App} from "@/App";
12 |
13 | ReactDOM.createRoot(document.getElementById("root")!).render();
14 |
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Projects */
4 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
5 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
6 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
7 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
8 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
9 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
10 |
11 | /* Language and Environment */
12 | "target": "esnext"
13 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
14 | "lib": [
15 | "dom",
16 | "dom.iterable",
17 | "esnext"
18 | ],
19 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */
20 | "jsx": "react-jsx"
21 | /* Specify what JSX code is generated. */,
22 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
23 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
24 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
25 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
26 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
27 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
28 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
29 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
30 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
31 |
32 | /* Modules */
33 | "module": "esnext"
34 | /* Specify what module code is generated. */,
35 | // "rootDir": "./", /* Specify the root folder within your source files. */
36 | "moduleResolution": "node",
37 | /* Specify how TypeScript looks up a file from a given module specifier. */
38 | "baseUrl": "./src"
39 | /* Specify the base directory to resolve non-relative module names. */,
40 | "paths": {
41 | "@/*": [
42 | "*"
43 | ]
44 | }
45 | /* Specify a set of entries that re-map imports to additional lookup locations. */,
46 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
47 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
48 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
49 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
50 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
51 | "resolveJsonModule": true
52 | /* Enable importing .json files. */,
53 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
54 |
55 | /* JavaScript Support */
56 | "allowJs": true
57 | /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */,
58 | "checkJs": false
59 | /* Enable error reporting in type-checked JavaScript files. */,
60 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
61 |
62 | /* Emit */
63 | // "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
64 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
65 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
66 | "sourceMap": true
67 | /* Create source map files for emitted JavaScript files. */,
68 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
69 | "outDir": "./build",
70 | /* Specify an output folder for all emitted files. */
71 | // "removeComments": true, /* Disable emitting comments. */
72 | "noEmit": true,
73 | /* Disable emitting files from a compilation. */
74 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
75 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
76 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
77 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
78 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
79 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
80 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
81 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
82 | // "newLine": "crlf", /* Set the newline character for emitting files. */
83 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
84 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
85 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
86 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
87 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
88 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
89 |
90 | /* Interop Constraints */
91 | "isolatedModules": true,
92 | /* Ensure that each file can be safely transpiled without relying on other imports. */
93 | "allowSyntheticDefaultImports": true,
94 | /* Allow 'import x from y' when a module doesn't have a default export. */
95 | "esModuleInterop": true
96 | /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
97 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
98 | "forceConsistentCasingInFileNames": true
99 | /* Ensure that casing is correct in imports. */,
100 | /* Type Checking */
101 | "strict": false
102 | /* Enable all strict type-checking options. */,
103 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
104 | "strictNullChecks": true
105 | /* When type checking, take into account 'null' and 'undefined'. */,
106 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
107 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
108 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
109 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
110 | "useUnknownInCatchVariables": true,
111 | /* Default catch clause variables as 'unknown' instead of 'any'. */
112 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
113 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
114 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
115 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
116 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
117 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
118 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
119 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
120 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
121 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
122 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
123 |
124 | /* Completeness */
125 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
126 | "skipLibCheck": true,
127 | /* Skip type checking all .d.ts files. */
128 | "types": [
129 | "vite/client",
130 | "vite-plugin-svgr/client"
131 | ]
132 | },
133 | "exclude": [
134 | "node_modules",
135 | "build"
136 | ],
137 | "include": [
138 | "./src"
139 | ]
140 | }
141 |
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": [
10 | "vite.config.ts"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import {defineConfig} from "vite";
3 | import checker from "vite-plugin-checker";
4 | import svgr from "vite-plugin-svgr";
5 | import viteTsconfigPaths from "vite-tsconfig-paths";
6 |
7 | export default defineConfig({
8 | plugins: [
9 | react(),
10 | checker({
11 | overlay: {initialIsOpen: false, position: "br"},
12 | typescript: true,
13 | eslint: {
14 | lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
15 | },
16 | }),
17 | viteTsconfigPaths(),
18 | svgr(),
19 | ],
20 |
21 | server: {
22 | host: "0.0.0.0",
23 | port: 5173,
24 | },
25 | build: {outDir: "build"},
26 |
27 | optimizeDeps: {
28 | esbuildOptions: {
29 | // Node.js global to browser globalThis
30 | define: {
31 | global: "globalThis",
32 | },
33 | },
34 | },
35 |
36 | css: {
37 | preprocessorOptions: {
38 | scss: {
39 | quietDeps: true,
40 | },
41 | },
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/server/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:@typescript-eslint/recommended"
9 | ],
10 | "parser": "@typescript-eslint/parser",
11 | "parserOptions": {
12 | "ecmaVersion": "latest",
13 | "sourceType": "module"
14 | },
15 | "plugins": [
16 | "@typescript-eslint",
17 | "simple-import-sort"
18 | ],
19 | "rules": {
20 | "simple-import-sort/imports": "error",
21 | "@typescript-eslint/no-unused-vars": "off",
22 | "@typescript-eslint/no-explicit-any": "off",
23 | "no-unused-vars": "off"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .env
27 | config/local*
28 | public
29 | db.sqlite
--------------------------------------------------------------------------------
/server/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "trailingComma": "es5",
4 | "tabWidth": 4,
5 | "singleQuote": false,
6 | "bracketSameLine": true,
7 | "bracketSpacing": false
8 | }
9 |
--------------------------------------------------------------------------------
/server/config/default.json5:
--------------------------------------------------------------------------------
1 | {
2 | bot: {
3 | testEnv: false,
4 | },
5 | server: {
6 | https: false,
7 | port: 3000,
8 | static: false,
9 | },
10 | gameServer: {
11 | validateInitData: true,
12 | inactivityTimeout: 300,
13 | disconnectTimeout: 180,
14 | fakeRoom: {
15 | create: false,
16 | },
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/server/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "button": {
3 | "wait": "Wait",
4 | "join": "Join",
5 | "play-again": "Play again",
6 | "play-another": "Play with someone else",
7 | "play": "Play"
8 | },
9 | "message": {
10 | "creating-room": "Creating a gaming session.\n\n_Please wait a moment..._",
11 | "not-started": {
12 | "invitation": "User {user} wants to play chess.",
13 | "rules": "*Game Rules:* {rules}",
14 | "guide": "_Click the button below to join the game._"
15 | },
16 | "in-game": {
17 | "introduction": "The game has started.",
18 | "description": "*Playing as White:* {whitePlayer}\n*Playing as Black:* {blackPlayer}",
19 | "rules": "*Game Rules:* {rules}",
20 | "guide": "_Click the button below to spectate the game._"
21 | },
22 | "finished": {
23 | "resolution": {
24 | "victory": {
25 | "white": "The game ended with a victory for White.",
26 | "black": "The game ended with a victory for Black."
27 | },
28 | "draw": "The game ended in a draw."
29 | },
30 | "explanation": {
31 | "checkmate": "{winner} checkmated {loser}.",
32 | "out-of-time": "{loser} ran out of time on the timer.\n*Winner:* {winner}.",
33 | "player-quit": "{loser} exited the game.\n*Winner:* {winner}.",
34 | "give-up": "{loser} gave up.\n*Winner:* {winner}.",
35 | "stalemate": "Stalemate",
36 | "draw": "Technical draw."
37 | },
38 | "description": "*Played as White:* {whitePlayer}\n*Played as Black:* {blackPlayer}",
39 | "guide": "_To create a new game invitation, click one of the buttons below or go to the chat which you want to send the invitation to, type in @{botName}, and add a space._"
40 | },
41 | "destroyed": "No one joined the game, the invitation is no longer valid.\n\n_To create a new game invitation, click one of the buttons below or go to the chat which you want to send the invitation to, type in @{botName}, and add a space._"
42 | },
43 | "rules": {
44 | "timer": {
45 | "absent": "No timer.",
46 | "present": "Timer: {start} min + {increment} sec."
47 | },
48 | "color-1st-person": {
49 | "random": "Random color.",
50 | "white": "You start as White.",
51 | "black": "You start as Black."
52 | },
53 | "color-3rd-person": {
54 | "random": "Random color.",
55 | "white": "The host starts as White.",
56 | "black": "The host starts as Black."
57 | },
58 | "custom": "Custom game"
59 | },
60 | "commands": {
61 | "start": "*Want to play chess with any contact from Telegram?*\nIt's very easy to do so, click the button below or go to the chat which you want to send the invitation to, type in @{botName}, and add a space.\nYou can also send the invitation to a group or channel. In that case, the first person to click the 'Join' button will be your opponent.",
62 | "stats": "*Your statistics:*\nGames played: *{userGamesPlayed}*\nGames won: *{userGamesWon}*\n\n*Bot statistics:*\nTotal unique users: *{totalUniqueUsers}*\nUnique users today: *{todayUniqueUsers}*\nTotal games played: *{totalGamesPlayed}*\nGames played today: *{todayGamesPlayed}*"
63 | }
64 | }
--------------------------------------------------------------------------------
/server/locales/ru.json:
--------------------------------------------------------------------------------
1 | {
2 | "button": {
3 | "wait": "Подождите",
4 | "join": "Присоедениться",
5 | "play-again": "Сыграть ещё раз",
6 | "play-another": "Сыграть с кем-то другим",
7 | "play": "Сыграть"
8 | },
9 | "message": {
10 | "creating-room": "Создаём игровую сессию.\n\n_Подождите немного..._",
11 | "not-started": {
12 | "invitation": "Пользователь {user} хочет сыграть в шахматы.",
13 | "rules": "*Правила игры:* {rules}",
14 | "guide": "_Нажмите на кнопку внизу что-бы присоедениться к игре._"
15 | },
16 | "in-game": {
17 | "introduction": "Игра началась.",
18 | "description": "*За белых играет:* {whitePlayer}\n*За чёрных играет:* {blackPlayer}",
19 | "rules": "*Правила игры:* {rules}",
20 | "guide": "_Нажмите на кнопку внизу что-бы наблюдать за игрой._"
21 | },
22 | "finished": {
23 | "resolution": {
24 | "victory": {
25 | "white": "Игра завершилась победой белых.",
26 | "black": "Игра завершилась победой чёрных."
27 | },
28 | "draw": "Игра завершилась ничьёй."
29 | },
30 | "explanation": {
31 | "checkmate": "{winner} поставил мат {loser}.",
32 | "out-of-time": "У {loser} истекло время на таймере.\n*Победитель:* {winner}.",
33 | "player-quit": "{loser} вышел из игры.\n*Победитель:* {winner}.",
34 | "give-up": "{loser} сдался.\n*Победитель:* {winner}.",
35 | "stalemate": "Пат",
36 | "draw": "Техническая ничья."
37 | },
38 | "description": "*За белых играл:* {whitePlayer}\n*За чёрных играл:* {blackPlayer}",
39 | "guide": "_Что бы создать новое приглашение в игру, нажмите на одну из кнопок ниже или зайдите в чат, в который вы хотите отправить приглашение, напишите @{botName} и поставьте пробел._"
40 | },
41 | "destroyed": "В игру так никто и не зашёл, приглашение больше недействительно.\n\n_Что бы создать новое приглашение в игру, нажмите на одну из кнопок ниже или зайдите в чат, в который вы хотите отправить приглашение, напишите @{botName} и поставьте пробел._"
42 | },
43 | "rules": {
44 | "timer": {
45 | "absent": "Без таймера.",
46 | "present": "Таймер: {start} мин + {increment} сек."
47 | },
48 | "color-1st-person": {
49 | "random": "Случайный цвет.",
50 | "white": "Вы начинаете за белых.",
51 | "black": "Вы начинаете за чёрных."
52 | },
53 | "color-3rd-person": {
54 | "random": "Случайный цвет.",
55 | "white": "Хост начинает за белых.",
56 | "black": "Хост начинает за чёрных."
57 | },
58 | "custom": "Своя игра"
59 | },
60 | "commands": {
61 | "start": "*Хотите сыграть в шахматы с любым контактом из Telegram?*\nЭто очень просто сделать, нажмите на кнопку внизу либо зайдите в чат, в который вы хотите отправить приглашение, напишите @{botName} и поставьте пробел.\nПриглашение так же можно отправить в группу или канал. Тогда вашим оппонентом будет тот, кто первый нажмёт на кнопку \"Присоедениться\".",
62 | "stats": "*Ваша статистика:*\nИгр сыграно: *{userGamesPlayed}*\nИгр выиграно: *{userGamesWon}*\n\n*Статистика бота:*\nВсего уникальных пользователей: *{totalUniqueUsers}*\nУникальных пользователей за сегодня: *{todayUniqueUsers}*\nВсего игр сыграно: *{totalGamesPlayed}*\nИгр сыграно за сегодня: *{todayGamesPlayed}*"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/server/locales/uk.json:
--------------------------------------------------------------------------------
1 | {
2 | "button": {
3 | "wait": "Зачекайте",
4 | "join": "Приєднатися",
5 | "play-again": "Зіграти ще раз",
6 | "play-another": "Зіграти з кимось іншим",
7 | "play": "Зіграти"
8 | },
9 | "message": {
10 | "creating-room": "Створюємо гру.\n\n_Зачекайте трошки..._",
11 | "not-started": {
12 | "invitation": "Користувач {user} бажає грати в шахи.",
13 | "rules": "*Правила гри:* {rules}",
14 | "guide": "_Натисніть на кнопку внизу, щоб приєднатися до гри._"
15 | },
16 | "in-game": {
17 | "introduction": "Гра розпочалася.",
18 | "description": "*Грають за білих:* {whitePlayer}\n*Грають за чорних:* {blackPlayer}",
19 | "rules": "*Правила гри:* {rules}",
20 | "guide": "_Натисніть на кнопку внизу, щоб спостерігати за грою._"
21 | },
22 | "finished": {
23 | "resolution": {
24 | "victory": {
25 | "white": "Гра завершилася перемогою білих.",
26 | "black": "Гра завершилася перемогою чорних."
27 | },
28 | "draw": "Гра завершилася нічиєю."
29 | },
30 | "explanation": {
31 | "checkmate": "{winner} виставив мат {loser}.",
32 | "out-of-time": "{loser} вичерпав час на таймері.\n*Переможець:* {winner}.",
33 | "player-quit": "{loser} вийшов з гри.\n*Переможець:* {winner}.",
34 | "give-up": "{loser} здався.\n*Переможець:* {winner}.",
35 | "stalemate": "Пат",
36 | "draw": "Технічна нічия."
37 | },
38 | "description": "*Грали за білих:* {whitePlayer}\n*Грали за чорних:* {blackPlayer}",
39 | "guide": "_Щоб створити нове запрошення до гри, натисніть на одну з кнопок нижче або зайдіть в чат, в який ви хочете відправити запрошення, напишіть @{botName} і поставте пробіл._"
40 | },
41 | "destroyed": "В гру так ніхто і не зайшов, запрошення більше неактивне.\n\n_Щоб створити нове запрошення до гри, натисніть на одну з кнопок нижче або зайдіть в чат, в який ви хочете відправити запрошення, напишіть @{botName} і поставте пробіл._"
42 | },
43 | "rules": {
44 | "timer": {
45 | "absent": "Без таймера.",
46 | "present": "Таймер: {start} хв + {increment} сек."
47 | },
48 | "color-1st-person": {
49 | "random": "Випадковий колір.",
50 | "white": "Ви починаєте за білих.",
51 | "black": "Ви починаєте за чорних."
52 | },
53 | "color-3rd-person": {
54 | "random": "Випадковий колір.",
55 | "white": "Хост починає за білих.",
56 | "black": "Хост починає за чорних."
57 | },
58 | "custom": "Власна гра"
59 | },
60 | "commands": {
61 | "start": "*Хочете зіграти в шахи з будь-яким контактом з Telegram?*\nЦе дуже просто зробити, натисніть на кнопку внизу або зайдіть в чат, в який ви хочете відправити запрошення, напишіть @{botName} і поставте пробіл.\nЗапрошення також можна відправити в групу або канал. Тоді вашим суперником буде той, хто перший натисне на кнопку \"Приєднатися\".",
62 | "stats": "*Ваша статистика:*\nІгор зіграно: *{userGamesPlayed}*\nІгор виграно: *{userGamesWon}*\n\n*Статистика бота:*\nВсього унікальних користувачів: *{totalUniqueUsers}*\nУнікальних користувачів за сьогодні: *{todayUniqueUsers}*\nВсього ігор зіграно: *{totalGamesPlayed}*\nІгор зіграно за сьогодні: *{todayGamesPlayed}*"
63 | }
64 | }
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chess-now-server",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "nodemon -r tsconfig-paths/register ./src/index.ts",
7 | "build": "rimraf ./dist && tsc",
8 | "start:dev": "npm run dev",
9 | "start:prod": "node -r tsconfig-paths/register dist"
10 | },
11 | "dependencies": {
12 | "@types/config": "^3.3.1",
13 | "@types/debug": "^4.1.9",
14 | "@types/i18n": "^0.13.6",
15 | "@types/js-yaml": "^4.0.6",
16 | "@types/module-alias": "^2.0.2",
17 | "@types/serve-static": "^1.15.3",
18 | "axios": "^1.5.0",
19 | "chess.js": "^1.0.0-beta.6",
20 | "config": "^3.3.9",
21 | "debug": "^4.3.4",
22 | "dotenv": "^16.3.1",
23 | "i18n": "^0.15.1",
24 | "luxon": "^3.4.3",
25 | "sequelize": "^6.33.0",
26 | "serve-static": "^1.15.0",
27 | "short-unique-id": "^5.0.3",
28 | "socket.io": "^4.7.2",
29 | "sqlite3": "^5.1.6",
30 | "telegraf": "^4.14.0"
31 | },
32 | "devDependencies": {
33 | "@types/luxon": "^3.3.2",
34 | "@types/node": "^20.6.3",
35 | "@typescript-eslint/eslint-plugin": "^6.7.3",
36 | "@typescript-eslint/parser": "^6.7.3",
37 | "eslint": "^8.50.0",
38 | "eslint-plugin-simple-import-sort": "^10.0.0",
39 | "nodemon": "^3.0.1",
40 | "prettier": "^3.0.3",
41 | "rimraf": "^5.0.5",
42 | "ts-node": "^10.9.1",
43 | "tsconfig-paths": "^4.2.0",
44 | "typescript": "^5.2.2"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/server/src/GameServer/DataModel.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Color of the chest pieces
3 | */
4 | export enum Color {
5 | White = "w",
6 | Black = "b",
7 | }
8 |
9 | /**
10 | * A 8x8 board square address in Algebraic notation
11 | * @see {@link https://en.wikipedia.org/wiki/Algebraic_notation_(chess)}
12 | */
13 | export enum Square {
14 | A8 = "a8",
15 | B8 = "b8",
16 | C8 = "c8",
17 | D8 = "d8",
18 | E8 = "e8",
19 | F8 = "f8",
20 | G8 = "g8",
21 | H8 = "h8",
22 | A7 = "a7",
23 | B7 = "b7",
24 | C7 = "c7",
25 | D7 = "d7",
26 | E7 = "e7",
27 | F7 = "f7",
28 | G7 = "g7",
29 | H7 = "h7",
30 | A6 = "a6",
31 | B6 = "b6",
32 | C6 = "c6",
33 | D6 = "d6",
34 | E6 = "e6",
35 | F6 = "f6",
36 | G6 = "g6",
37 | H6 = "h6",
38 | A5 = "a5",
39 | B5 = "b5",
40 | C5 = "c5",
41 | D5 = "d5",
42 | E5 = "e5",
43 | F5 = "f5",
44 | G5 = "g5",
45 | H5 = "h5",
46 | A4 = "a4",
47 | B4 = "b4",
48 | C4 = "c4",
49 | D4 = "d4",
50 | E4 = "e4",
51 | F4 = "f4",
52 | G4 = "g4",
53 | H4 = "h4",
54 | A3 = "a3",
55 | B3 = "b3",
56 | C3 = "c3",
57 | D3 = "d3",
58 | E3 = "e3",
59 | F3 = "f3",
60 | G3 = "g3",
61 | H3 = "h3",
62 | A2 = "a2",
63 | B2 = "b2",
64 | C2 = "c2",
65 | D2 = "d2",
66 | E2 = "e2",
67 | F2 = "f2",
68 | G2 = "g2",
69 | H2 = "h2",
70 | A1 = "a1",
71 | B1 = "b1",
72 | C1 = "c1",
73 | D1 = "d1",
74 | E1 = "e1",
75 | F1 = "f1",
76 | G1 = "g1",
77 | H1 = "h1",
78 | }
79 |
80 | /**
81 | * Piece symbol (without color)
82 | */
83 | export enum PieceSymbol {
84 | Pawn = "p",
85 | Knight = "n",
86 | Bishop = "b",
87 | Rook = "r",
88 | Queen = "q",
89 | King = "k",
90 | }
91 |
92 | /**
93 | * Represents a user authenticated through Telegram
94 | */
95 | export interface User {
96 | /**
97 | * Telegram user's id
98 | * @see {@link https://core.telegram.org/bots/api#user}
99 | */
100 | id: number;
101 |
102 | /**
103 | * Telegram user's first_name and last_name joined
104 | * @see {@link https://core.telegram.org/bots/api#user}
105 | */
106 | fullName: string;
107 |
108 | /**
109 | * Telegram user's username
110 | * @see {@link https://core.telegram.org/bots/api#user}
111 | */
112 | username?: string;
113 |
114 | /**
115 | * Data-URL of Telegram user's first profile picture
116 | */
117 | avatarURL?: string;
118 | }
119 |
120 | /**
121 | * Represents dynamic state of room member
122 | */
123 | export interface MemberState {
124 | /**
125 | * Whether the member has active connection to the room. A member could be present in the room but not connected,
126 | * for example if he/she leaves mid-game.
127 | *
128 | * Also, the user who created the room (the host) will always be present in the room (even if not connected)
129 | */
130 | connected: boolean;
131 |
132 | /**
133 | * Whether the member has ability to participate in the game (e.g. make moves). All members have ability to watch
134 | * the game by default.
135 | */
136 | isPlayer: boolean;
137 |
138 | /**
139 | * The color of the chest pieces the player controls. Guaranteed to be unique in the room.
140 | *
141 | * Present if {@link isPlayer} equals true
142 | */
143 | color?: Color;
144 | }
145 |
146 | /**
147 | * Represents a member of the room and its state
148 | * @see Room
149 | */
150 | export interface Member {
151 | /**
152 | * User profile of this member
153 | */
154 | user: User;
155 |
156 | /**
157 | * Member's state
158 | */
159 | state: MemberState;
160 | }
161 |
162 | /**
163 | * Represents the parameters of the game in current room, that could be set up before room creation
164 | * @see Room.gameRules
165 | */
166 | export interface GameRules {
167 | /**
168 | * If host selected to play as a specific color, this field is equal to that color. Undefined is it should be picked
169 | * randomly.
170 | */
171 | hostPreferredColor?: Color;
172 |
173 | /**
174 | * Whether players are limited in time to make a move. The timer does not reset when player makes a move. If the
175 | * player runs out of time he/she looses.
176 | */
177 | timer: boolean;
178 |
179 | /**
180 | * Initial time on the timer for each player. Expressed in seconds.
181 | *
182 | * Present if {@link timer} equals true.
183 | */
184 | initialTime?: number;
185 |
186 | /**
187 | * Amount by which the timer of the player should be incremented after he/she made a move
188 | *
189 | * Present if {@link plays} equals true.
190 | */
191 | timerIncrement?: number;
192 | }
193 |
194 | /**
195 | * Status of the game in the current room
196 | * @see GameState.status
197 | */
198 | export enum GameStatus {
199 | NotStarted = "not-started",
200 | InProgress = "in-progress",
201 | Finished = "finished",
202 | }
203 |
204 | /**
205 | * The result which the game was finished with
206 | */
207 | export enum GameResolution {
208 | /**
209 | * Means in any game position a player's king is in check
210 | *
211 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
212 | * @see {@link https://en.wikipedia.org/wiki/Checkmate}
213 | */
214 | Checkmate = "checkmate",
215 |
216 | /**
217 | * One of the players run out of timer time
218 | *
219 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
220 | */
221 | OutOfTime = "out-of-time",
222 |
223 | /**
224 | * One of the players disconnected from the game and did not reconnect back in time
225 | *
226 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
227 | */
228 | PlayerQuit = "player-quit",
229 |
230 | /**
231 | * One of the players gave up
232 | *
233 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set
234 | */
235 | GiveUp = "give-up",
236 |
237 | /**
238 | * A situation where the player whose turn it is to move is not in check and has no legal move
239 | *
240 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set
241 | * @see https://en.wikipedia.org/wiki/Stalemate
242 | */
243 | Stalemate = "stalemate",
244 |
245 | /**
246 | * Draw
247 | *
248 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set
249 | * @see https://en.wikipedia.org/wiki/Draw_(chess)
250 | */
251 | Draw = "draw",
252 | }
253 |
254 | /**
255 | * State of the game in current room
256 | */
257 | export interface GameState {
258 | /**
259 | * Whether the game not-started, started or finished
260 | * @see GameStatus
261 | */
262 | status: GameStatus;
263 |
264 | /**
265 | * History of all moves in PGN notation
266 | * @see {@link https://en.wikipedia.org/wiki/Portable_Game_Notation}
267 | */
268 | pgn: string;
269 |
270 | /**
271 | * Whose side the current turn is
272 | */
273 | turn: Color;
274 |
275 | /**
276 | * State of player's timers. Defined if {@link GameRules.timer} equals true.
277 | */
278 | timer?: TimerState;
279 |
280 | /**
281 | * How the game finished
282 | *
283 | * Present if {@link status} equals {@link GameStatus.Finished}
284 | */
285 | resolution?: GameResolution;
286 |
287 | /**
288 | * If the game finished in a win of one player, the ID of the user, who won the game
289 | */
290 | winnerID?: number;
291 | }
292 |
293 | /**
294 | * Represents a game session, which players or spectators could connect
295 | */
296 | export interface Room {
297 | /**
298 | * Unique string identifier 24 characters long
299 | */
300 | id: string;
301 |
302 | /**
303 | * List of members currently present in the room. Not all present members are guaranteed to be connected.
304 | * @see MemberState.connected
305 | */
306 | members: Member[];
307 |
308 | /**
309 | * ID of the user who created the room. Guaranteed to be present in the {@link members}.
310 | * @see User.id
311 | */
312 | hostID: number;
313 |
314 | /**
315 | * UNIX time (number of seconds since 00:00:00 UTC January 1, 1970) of the moment, when this room was created
316 | * @see {@link https://en.wikipedia.org/wiki/Unix_time}
317 | */
318 | createdTimestamp: number;
319 |
320 | /**
321 | * Parameters of the game
322 | * @see GameRules
323 | */
324 | gameRules: GameRules;
325 |
326 | /**
327 | * State of the game
328 | * @see GameState
329 | */
330 | gameState: GameState;
331 | }
332 |
333 | /**
334 | * Represents a chess move
335 | */
336 | export interface Move {
337 | /**
338 | * Which square the piece was moved from
339 | */
340 | from: Square;
341 |
342 | /**
343 | * Which square the piece was moved to
344 | */
345 | to: Square;
346 |
347 | /**
348 | * Present if the pawn was promoted to another piece. Contains which piece it was promoted to.
349 | */
350 | promotion?: PieceSymbol;
351 | }
352 |
353 | /**
354 | * Represents a state of two player timers
355 | */
356 | export interface TimerState {
357 | /**
358 | * Amount of time in milliseconds left on white player's timer
359 | */
360 | whiteTimeLeft: number;
361 |
362 | /**
363 | * Amount of time in milliseconds left on black player's timer
364 | */
365 | blackTimeLeft: number;
366 | }
367 |
368 | /**
369 | * Payload sent by client with new connection
370 | */
371 | export interface AuthPayload {
372 | /**
373 | * initData field from window.Telegram.WebApp in the client. Needed fot authentication. Its genuinity will be
374 | * verified by the server.
375 | * @see {@link https://core.telegram.org/bots/webapps#initializing-mini-apps}
376 | * @see {@link https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app}
377 | */
378 | initData: string;
379 | }
380 |
381 | /**
382 | * List of events that server could send to connected client
383 | */
384 | export interface ServerToClientEvents {
385 | /**
386 | * Sent when error happens while processing client event. After an error happens, server closes the connection with
387 | * the client.
388 | * @param name Name of the Error class
389 | * @param message Message explaining the details of the error in english
390 | */
391 | error: (name: string, message: string) => void;
392 |
393 | /**
394 | * Sent to client, when it initially connects to the room
395 | * @param room Room details the client connected to
396 | * @param userID ID of the user this connection is assigned to
397 | */
398 | init: (room: Room, userID: number) => void;
399 |
400 | /**
401 | * Sent when another user joins the room
402 | * @param user The user who joined the room
403 | */
404 | memberJoin: (member: Member) => void;
405 |
406 | /**
407 | * Sent when the members, present in the room leaves it. When the game begins, users who became players
408 | * ({@link MemberState.isPlayer} equals true) can not leave, they only can disconnect, and
409 | * {@link ServerToClientEvents.memberUpdate} will be called in this case.
410 | * @param userID ID of the user who left the room
411 | */
412 | memberLeave: (userID: number) => void;
413 |
414 | /**
415 | * Sent when the member's state, that is currently present in the room updated
416 | * @param userID ID of the user, whose state is updated
417 | * @param state New state of the member
418 | */
419 | memberUpdate: (userID: number, state: MemberState) => void;
420 |
421 | /**
422 | * Sent when current gameState of the room transitions from {@link GameStatus.NotStarted} to
423 | * {@link GameStatus.InProgress}
424 | */
425 | gameStart: () => void;
426 |
427 | /**
428 | * Sent when current gameState of the room transitions from {@link GameStatus.InProgress} to
429 | * {@link GameStatus.Finished}
430 | * @param resolution How the game ended
431 | * @param winnerID ID of the user who won
432 | * @param timer Last timer recordings. Defined if {@link GameRules.timer} equals true.
433 | * @see GameState.winnerID
434 | */
435 | gameEnd: (resolution: GameResolution, winnerID?: number, timer?: TimerState) => void;
436 |
437 | /**
438 | * Sent when a move was registered by server
439 | * @param move Move that was registered
440 | * @param timer State of player's timers. Defined if {@link GameRules.timer} equals true.
441 | */
442 | move: (move: Move, timer?: TimerState) => void;
443 | }
444 |
445 | /**
446 | * List of events that client could send to server
447 | */
448 | export interface ClientToServerEvents {
449 | /**
450 | * Make a move. The move should be validated on both server and client sides. If the move is illegal, or it is not
451 | * the players turn, server will return an error and close the connection.
452 | * @param move A move requested by client
453 | */
454 | makeMove: (move: Move) => void;
455 |
456 | /**
457 | * User agreed to take a loss and voluntarily give up
458 | */
459 | giveUp: () => void;
460 | }
461 |
--------------------------------------------------------------------------------
/server/src/GameServer/Database.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Association,
3 | BelongsToCreateAssociationMixin,
4 | BelongsToGetAssociationMixin,
5 | BelongsToSetAssociationMixin,
6 | CreationOptional,
7 | DataTypes,
8 | ForeignKey,
9 | HasManyAddAssociationMixin,
10 | HasManyAddAssociationsMixin,
11 | HasManyCountAssociationsMixin,
12 | HasManyCreateAssociationMixin,
13 | HasManyGetAssociationsMixin,
14 | HasManyHasAssociationMixin,
15 | HasManyHasAssociationsMixin,
16 | HasManyRemoveAssociationMixin,
17 | HasManyRemoveAssociationsMixin,
18 | HasManySetAssociationsMixin,
19 | InferAttributes,
20 | InferCreationAttributes,
21 | Model,
22 | Sequelize,
23 | } from "sequelize";
24 |
25 | import {Color, GameResolution} from "@/GameServer/DataModel.js";
26 |
27 | const sequelize = new Sequelize({
28 | dialect: "sqlite",
29 | storage: "db.sqlite",
30 | logging: false,
31 | define: {
32 | freezeTableName: true,
33 | },
34 | });
35 |
36 | export class UserProfile extends Model, InferCreationAttributes> {
37 | declare id: number;
38 | declare fullName: string;
39 | declare username: string | null;
40 | declare languageCode: string | null;
41 |
42 | declare createdAt: CreationOptional;
43 | declare updatedAt: CreationOptional;
44 |
45 | declare getWhiteGames: HasManyGetAssociationsMixin;
46 | declare addWhiteGame: HasManyAddAssociationMixin;
47 | declare addWhiteGames: HasManyAddAssociationsMixin;
48 | declare setWhiteGames: HasManySetAssociationsMixin;
49 | declare removeWhiteGame: HasManyRemoveAssociationMixin;
50 | declare removeWhiteGames: HasManyRemoveAssociationsMixin;
51 | declare hasWhiteGame: HasManyHasAssociationMixin;
52 | declare hasWhiteGames: HasManyHasAssociationsMixin;
53 | declare countWhiteGames: HasManyCountAssociationsMixin;
54 | declare createWhiteGame: HasManyCreateAssociationMixin;
55 |
56 | declare getBlackGames: HasManyGetAssociationsMixin;
57 | declare addBlackGame: HasManyAddAssociationMixin;
58 | declare addBlackGames: HasManyAddAssociationsMixin;
59 | declare setBlackGames: HasManySetAssociationsMixin;
60 | declare removeBlackGame: HasManyRemoveAssociationMixin;
61 | declare removeBlackGames: HasManyRemoveAssociationsMixin;
62 | declare hasBlackGame: HasManyHasAssociationMixin;
63 | declare hasBlackGames: HasManyHasAssociationsMixin;
64 | declare countBlackGames: HasManyCountAssociationsMixin;
65 | declare createBlackGame: HasManyCreateAssociationMixin;
66 |
67 | declare static associations: {
68 | whiteGames: Association;
69 | blackGames: Association;
70 | };
71 | }
72 |
73 | UserProfile.init(
74 | {
75 | id: {
76 | type: DataTypes.INTEGER,
77 | primaryKey: true,
78 | allowNull: false,
79 | },
80 | fullName: {
81 | type: DataTypes.STRING,
82 | allowNull: false,
83 | },
84 | username: {
85 | type: DataTypes.STRING,
86 | defaultValue: null,
87 | },
88 | languageCode: {
89 | type: DataTypes.STRING,
90 | defaultValue: null,
91 | },
92 | createdAt: DataTypes.DATE,
93 | updatedAt: DataTypes.DATE,
94 | },
95 | {sequelize}
96 | );
97 |
98 | export class PlayedGame extends Model, InferCreationAttributes> {
99 | declare id: string;
100 | declare timerEnabled: boolean;
101 | declare timerInit: number;
102 | declare timerIncrement: number;
103 | declare pgn: string;
104 | declare resolution: GameResolution;
105 | declare winner: Color | null;
106 |
107 | declare createdAt: CreationOptional;
108 |
109 | declare whitePlayerID: ForeignKey;
110 | declare blackPlayerID: ForeignKey;
111 |
112 | declare getWhitePlayer: BelongsToGetAssociationMixin;
113 | declare setWhitePlayer: BelongsToSetAssociationMixin;
114 | declare createWhitePlayer: BelongsToCreateAssociationMixin;
115 |
116 | declare getBlackPlayer: BelongsToGetAssociationMixin;
117 | declare setBlackPlayer: BelongsToSetAssociationMixin;
118 | declare createBlackPlayer: BelongsToCreateAssociationMixin;
119 |
120 | declare static associations: {
121 | whitePlayer: Association;
122 | blackPlayer: Association;
123 | };
124 | }
125 |
126 | PlayedGame.init(
127 | {
128 | id: {
129 | type: DataTypes.STRING,
130 | primaryKey: true,
131 | allowNull: false,
132 | },
133 | timerEnabled: {
134 | type: DataTypes.BOOLEAN,
135 | allowNull: false,
136 | },
137 | timerInit: {
138 | type: DataTypes.NUMBER,
139 | allowNull: false,
140 | },
141 | timerIncrement: {
142 | type: DataTypes.NUMBER,
143 | allowNull: false,
144 | },
145 | pgn: {
146 | type: DataTypes.STRING,
147 | allowNull: false,
148 | },
149 | resolution: {
150 | type: DataTypes.STRING,
151 | allowNull: false,
152 | },
153 | winner: {
154 | type: DataTypes.STRING,
155 | allowNull: true,
156 | },
157 | createdAt: DataTypes.DATE,
158 | },
159 | {
160 | sequelize,
161 | updatedAt: false,
162 | indexes: [
163 | {
164 | unique: false,
165 | fields: ["winner"],
166 | },
167 | {
168 | unique: false,
169 | fields: ["whitePlayerID"],
170 | },
171 | {
172 | unique: false,
173 | fields: ["blackPlayerID"],
174 | },
175 | ],
176 | }
177 | );
178 |
179 | UserProfile.hasMany(PlayedGame, {
180 | as: "whiteGames",
181 | foreignKey: {
182 | name: "whitePlayerID",
183 | allowNull: false,
184 | },
185 | onDelete: "RESTRICT",
186 | onUpdate: "RESTRICT",
187 | });
188 | PlayedGame.belongsTo(UserProfile, {
189 | as: "whitePlayer",
190 | foreignKey: {
191 | name: "whitePlayerID",
192 | allowNull: false,
193 | },
194 | onDelete: "RESTRICT",
195 | onUpdate: "RESTRICT",
196 | });
197 |
198 | UserProfile.hasMany(PlayedGame, {
199 | as: "blackGames",
200 | foreignKey: {
201 | name: "blackPlayerID",
202 | allowNull: false,
203 | },
204 | onDelete: "RESTRICT",
205 | onUpdate: "RESTRICT",
206 | });
207 | PlayedGame.belongsTo(UserProfile, {
208 | as: "blackPlayer",
209 | foreignKey: {
210 | name: "blackPlayerID",
211 | allowNull: false,
212 | },
213 | onDelete: "RESTRICT",
214 | onUpdate: "RESTRICT",
215 | });
216 |
217 | (async () => {
218 | await sequelize.sync();
219 | })();
220 |
--------------------------------------------------------------------------------
/server/src/GameServer/Errors.ts:
--------------------------------------------------------------------------------
1 | export class AlreadyConnectedError extends Error {
2 | constructor(message?: string) {
3 | super(message);
4 | this.name = "AlreadyConnectedError";
5 | }
6 | }
7 |
8 | export class AuthError extends Error {
9 | constructor(message?: string) {
10 | super(message);
11 | this.name = "AuthError";
12 | }
13 | }
14 |
15 | export class RoomNotFoundError extends Error {
16 | constructor(message?: string) {
17 | super(message);
18 | this.name = "RoomNotFoundError";
19 | }
20 | }
21 |
22 | export class IllegalMoveError extends Error {
23 | constructor(message?: string) {
24 | super(message);
25 | this.name = "IllegalMoveError";
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/server/src/GameServer/GameServer.ts:
--------------------------------------------------------------------------------
1 | import config from "config";
2 | import debug from "debug";
3 | import http from "http";
4 | import https from "https";
5 | import * as SocketIO from "socket.io";
6 |
7 | import {UserProfile} from "@/GameServer/Database";
8 | import {AuthPayload, ClientToServerEvents, GameRules, ServerToClientEvents, User} from "@/GameServer/DataModel";
9 | import {AuthError, RoomNotFoundError} from "@/GameServer/Errors";
10 | import {ServerRoom} from "@/GameServer/ServerRoom";
11 | import {catchErrors} from "@/GameServer/SocketErrorHandler";
12 | import {bot} from "@/index";
13 | import {joinFullName} from "@/Telegram/joinFullname";
14 | import {parseInitData} from "@/Telegram/parseInitData";
15 | import {WebAppInitData} from "@/Telegram/Types";
16 |
17 | const log = debug("GameServer");
18 |
19 | export type Server = SocketIO.Server<
20 | ClientToServerEvents,
21 | ServerToClientEvents,
22 | Record,
23 | Record
24 | >;
25 | export type Socket = SocketIO.Socket<
26 | ClientToServerEvents,
27 | ServerToClientEvents,
28 | Record,
29 | Record
30 | >;
31 |
32 | export class GameServer {
33 | private readonly io: Server;
34 |
35 | private readonly rooms: {[key: string]: ServerRoom};
36 |
37 | private readonly validateInitData: boolean;
38 |
39 | constructor(server?: http.Server | https.Server) {
40 | this.io = new SocketIO.Server(server, {
41 | cors: {
42 | origin: "*",
43 | },
44 | pingInterval: 2000,
45 | pingTimeout: 2000,
46 | transports: ["polling", "websocket"],
47 | });
48 |
49 | this.rooms = {};
50 |
51 | this.validateInitData = config.get("gameServer.validateInitData");
52 |
53 | this.io.on("connect", (socket: Socket) => {
54 | catchErrors(socket)(this.handleConnection)(socket);
55 | });
56 |
57 | log("GameServer instance was created");
58 |
59 | if (config.get("gameServer.fakeRoom.create")) {
60 | const id = config.get("gameServer.fakeRoom.id");
61 | const host = config.get("gameServer.fakeRoom.host");
62 | const fakeRoomGameRules = config.get("gameServer.fakeRoom.gameRules");
63 |
64 | this.createRoom(host, fakeRoomGameRules, id);
65 | log('Fake room("%s") was created', id);
66 | }
67 | }
68 |
69 | public get roomCount(): number {
70 | return Object.keys(this.rooms).length;
71 | }
72 |
73 | public createRoom = (host: User, gameRules: GameRules, id?: string): ServerRoom => {
74 | const room = new ServerRoom(host, gameRules, id);
75 | this.rooms[room.id] = room;
76 | room.on("destroy", () => {
77 | this.handleRoomDestroy(room.id);
78 | });
79 | return room;
80 | };
81 |
82 | private handleRoomDestroy = async (roomID: string) => {
83 | delete this.rooms[roomID];
84 | log('Room("%s") was destroyed', roomID);
85 | };
86 |
87 | private handleConnection = async (socket: Socket) => {
88 | log("New connection");
89 |
90 | const authPayload = socket.handshake.auth as AuthPayload;
91 |
92 | const initData: WebAppInitData = parseInitData(authPayload.initData, this.validateInitData);
93 |
94 | const roomID = initData.start_param;
95 |
96 | if (!roomID) {
97 | throw new AuthError('Launched with no "startapp" param');
98 | }
99 |
100 | if (!initData.user) {
101 | throw new AuthError("Information about the user was not provided");
102 | }
103 |
104 | if (!this.rooms[roomID]) {
105 | throw new RoomNotFoundError(`Room with id "${roomID}" was not found`);
106 | }
107 |
108 | const user: User = {
109 | id: initData.user.id,
110 | fullName: joinFullName(initData.user.first_name, initData.user.last_name),
111 | username: initData.user.username,
112 | avatarURL: await bot.getAvatar(initData.user.id),
113 | };
114 |
115 | await UserProfile.upsert({
116 | id: user.id,
117 | fullName: user.fullName,
118 | username: user.username,
119 | languageCode: initData.user.language_code,
120 | });
121 |
122 | log("User(%d: %s) trying to connect to room %s", user.id, user.fullName, roomID);
123 |
124 | //The user may have already disconnected while the server was retrieving his profile picture
125 | if (socket.connected) {
126 | this.rooms[roomID].acceptConnection(socket, user);
127 | }
128 | };
129 | }
130 |
--------------------------------------------------------------------------------
/server/src/GameServer/PausableTimer.ts:
--------------------------------------------------------------------------------
1 | import {DateTime} from "luxon";
2 |
3 | export class PausableTimer {
4 | private readonly callback: () => void;
5 |
6 | private _timeLeft: number;
7 |
8 | private startTime: DateTime | null;
9 |
10 | private timeoutID: NodeJS.Timeout | null;
11 |
12 | constructor(callback: () => void, timeout?: number) {
13 | this.callback = callback;
14 | if (timeout) {
15 | this._timeLeft = timeout;
16 | } else {
17 | this._timeLeft = 0;
18 | }
19 |
20 | this.startTime = null;
21 | this.timeoutID = null;
22 | }
23 |
24 | public get isGoing(): boolean {
25 | return this.startTime !== null;
26 | }
27 |
28 | public get timeLeft(): number {
29 | if (this.startTime) {
30 | const timeSpent = DateTime.now().toMillis() - this.startTime.toMillis();
31 | return this._timeLeft - timeSpent;
32 | }
33 |
34 | return this._timeLeft;
35 | }
36 |
37 | public readonly start = (timeout?: number): void => {
38 | if (timeout !== undefined) {
39 | this._timeLeft = timeout;
40 | }
41 |
42 | this.timeoutID = setTimeout(this.handleTimeout, this._timeLeft);
43 | this.startTime = DateTime.now();
44 | };
45 |
46 | public readonly pause = (): void => {
47 | if (!this.timeoutID || !this.startTime) {
48 | return;
49 | }
50 | clearTimeout(this.timeoutID);
51 |
52 | const timeSpent = DateTime.now().toMillis() - this.startTime.toMillis();
53 | this._timeLeft -= timeSpent;
54 | if (this.timeLeft < 0) {
55 | this._timeLeft = 0;
56 | }
57 |
58 | this.timeoutID = null;
59 | this.startTime = null;
60 | };
61 |
62 | public readonly stop = (): void => {
63 | if (!this.timeoutID || !this.startTime) {
64 | return;
65 | }
66 | clearTimeout(this.timeoutID);
67 |
68 | this._timeLeft = 0;
69 |
70 | this.timeoutID = null;
71 | this.startTime = null;
72 | };
73 |
74 | public readonly addTime = (time: number): void => {
75 | if (this.isGoing) {
76 | this.pause();
77 | this._timeLeft += time;
78 | this.start();
79 | } else {
80 | this._timeLeft += time;
81 | }
82 | };
83 |
84 | private readonly handleTimeout = (): void => {
85 | this._timeLeft = 0;
86 | this.startTime = null;
87 | this.timeoutID = null;
88 | this.callback();
89 | };
90 | }
91 |
--------------------------------------------------------------------------------
/server/src/GameServer/SocketErrorHandler.ts:
--------------------------------------------------------------------------------
1 | import debug from "debug";
2 |
3 | import {Socket} from "@/GameServer/GameServer";
4 |
5 | const log = debug("GameServer:SocketErrorHandler");
6 |
7 | type CallbackType = (...args: CallbackParameters) => void | Promise;
8 |
9 | export const catchErrors = (socket?: Socket) => {
10 | return (callback: CallbackType): CallbackType => {
11 | const handleError = (err: unknown) => {
12 | if (err instanceof Error) {
13 | if (socket && socket.connected) {
14 | let name = err.name;
15 | const message = err.message;
16 |
17 | if (!name) {
18 | name = "Unknown";
19 | }
20 |
21 | try {
22 | socket.emit("error", name, message);
23 | socket.disconnect();
24 | log("Socket was disconnected due to error: %O", err);
25 | } catch (e) {
26 | log("A new error was thrown while handling error: %O", e);
27 | }
28 | } else {
29 | if (socket) {
30 | log("An error happened, but no socket was provided: %O", err);
31 | } else {
32 | log("An error happened, but socket is already disconnected: %O", err);
33 | }
34 | }
35 | } else {
36 | if (socket && socket.connected) {
37 | try {
38 | socket.disconnect();
39 | } catch (e) {
40 | log("A new error was thrown while closing the connection: %O", e);
41 | }
42 | }
43 | log("Thrown error was not an instance of Error class: %o", err);
44 | }
45 | };
46 |
47 | return (...args: CallbackParameters): void | Promise => {
48 | try {
49 | const ret = callback.apply(this, args);
50 | if (ret && typeof ret.catch === "function") {
51 | // async handler
52 | ret.catch(handleError);
53 | }
54 | return ret;
55 | } catch (e) {
56 | // sync handler
57 | handleError(e);
58 | }
59 | };
60 | };
61 | };
62 |
--------------------------------------------------------------------------------
/server/src/GameServer/TypedEventEmitter.ts:
--------------------------------------------------------------------------------
1 | import {EventEmitter} from "events";
2 |
3 | export class TypedEventEmitter> {
4 | private emitter = new EventEmitter();
5 |
6 | protected emit(eventName: TEventName, ...eventArg: TEvents[TEventName]) {
7 | this.emitter.emit(eventName, ...(eventArg as []));
8 | }
9 |
10 | public on(
11 | eventName: TEventName,
12 | handler: (...eventArg: TEvents[TEventName]) => void
13 | ) {
14 | this.emitter.on(eventName, handler as unknown as any);
15 | }
16 |
17 | public off(
18 | eventName: TEventName,
19 | handler: (...eventArg: TEvents[TEventName]) => void
20 | ) {
21 | this.emitter.off(eventName, handler as any);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/Telegram/ChessNowBot.ts:
--------------------------------------------------------------------------------
1 | import {UserProfilePhotos} from "@telegraf/types/manage";
2 | import {InlineKeyboardButton} from "@telegraf/types/markup";
3 | import {PhotoSize} from "@telegraf/types/message";
4 | import axios from "axios";
5 | import config from "config";
6 | import debug from "debug";
7 | import i18n from "i18n";
8 | import {isAbsolute} from "path";
9 | import {Op} from "sequelize";
10 | import {Context, NarrowedContext, Telegraf} from "telegraf";
11 | import * as tg from "telegraf/src/core/types/typegram";
12 | import {URL} from "url";
13 |
14 | import {PlayedGame, UserProfile} from "@/GameServer/Database";
15 | import {Color, GameRules, GameStatus, User} from "@/GameServer/DataModel";
16 | import {GameServer} from "@/GameServer/GameServer";
17 | import {catchErrors} from "@/GameServer/SocketErrorHandler";
18 | import {joinFullName} from "@/Telegram/joinFullname";
19 | import {MessageGenerator} from "@/Telegram/MessageGenerator";
20 | import {parseGameRulesQuery} from "@/Telegram/parseGameRulesQuery";
21 |
22 | interface InlineGameDescription {
23 | id: string;
24 | title: string;
25 | gameRules: GameRules;
26 | thumbnailUrl?: string;
27 | }
28 |
29 | type PredefinedGamesConfig = {[key: string]: InlineGameDescription};
30 |
31 | const log = debug("ChessNowBot");
32 |
33 | export class ChessNowBot {
34 | private readonly bot: Telegraf;
35 |
36 | private readonly gameServer: GameServer;
37 |
38 | private readonly token: string;
39 |
40 | private readonly predefinedGames: PredefinedGamesConfig;
41 |
42 | private readonly customThumbnailURL: string | undefined;
43 |
44 | private readonly webAppCustomizeURL: string;
45 |
46 | private readonly webAppGameName: string;
47 |
48 | private readonly messageGenerators: {[key: string]: MessageGenerator};
49 |
50 | constructor(gameServer: GameServer) {
51 | this.gameServer = gameServer;
52 |
53 | this.token = config.get("bot.token");
54 |
55 | this.predefinedGames = {};
56 |
57 | if (config.has("gameServer.gameModes")) {
58 | const predefinedGamesConfig = config.get("gameServer.gameModes");
59 |
60 | for (const id of Object.keys(predefinedGamesConfig)) {
61 | if (id !== "custom") {
62 | this.predefinedGames[id] = Object.assign({id: "#" + id}, predefinedGamesConfig[id]);
63 | }
64 | }
65 | }
66 |
67 | if (config.has("gameServer.gameModes.custom.thumbnailUrl")) {
68 | this.customThumbnailURL = config.get("gameServer.gameModes.custom.thumbnailUrl");
69 | }
70 |
71 | this.webAppCustomizeURL = config.get("webApp.customize.url");
72 |
73 | this.webAppGameName = config.get("webApp.game.name");
74 |
75 | this.messageGenerators = {};
76 |
77 | for (const locale of i18n.getLocales()) {
78 | this.messageGenerators[locale] = new MessageGenerator(locale);
79 | }
80 |
81 | this.bot = new Telegraf(this.token, {
82 | telegram: {
83 | testEnv: config.get("bot.testEnv"),
84 | },
85 | });
86 |
87 | this.bot.on("inline_query", this.handleInlineQuery);
88 | this.bot.on("chosen_inline_result", this.handleInlineResult);
89 | this.bot.command("start", this.handleStartCommand);
90 | this.bot.command("stats", this.handleStatsCommand);
91 |
92 | this.bot.catch((err, ctx) => {
93 | console.error(err);
94 | });
95 |
96 | log("ChessNowBot instance was created");
97 | }
98 |
99 | public readonly launch = async (): Promise => {
100 | log("ChessNowBot has been launched");
101 | await this.bot.launch();
102 | };
103 |
104 | public readonly getAvatar = async (userID: number): Promise => {
105 | let userProfilePhotos: UserProfilePhotos;
106 | try {
107 | userProfilePhotos = await this.bot.telegram.getUserProfilePhotos(userID, 0, 1);
108 | } catch (e) {
109 | return undefined;
110 | }
111 |
112 | if (userProfilePhotos.total_count === 0) {
113 | return undefined;
114 | }
115 |
116 | const avatarSizes = userProfilePhotos.photos[0];
117 |
118 | //Select the smallest PhotoSize that is still larger than targetResolution
119 | const targetResolution = 128;
120 | avatarSizes.sort((a, b) => b.width - a.width);
121 | let selectedSize: PhotoSize = avatarSizes[0];
122 | for (const size of avatarSizes) {
123 | if (size.width > targetResolution && size.height > targetResolution) {
124 | selectedSize = size;
125 | }
126 | }
127 |
128 | const fileURL = await this.getFileLink(selectedSize.file_id);
129 |
130 | const response = await axios.get(fileURL.href, {
131 | responseType: "arraybuffer",
132 | });
133 | const returnedB64 = Buffer.from(response.data).toString("base64");
134 |
135 | return "data:image/jpeg;base64," + returnedB64;
136 | };
137 |
138 | private readonly getMessageGenerator = (locale?: string): MessageGenerator => {
139 | if (locale) {
140 | return (
141 | this.messageGenerators[locale] ||
142 | this.messageGenerators[i18n.getLocale()] ||
143 | this.messageGenerators["en"]
144 | );
145 | }
146 |
147 | return this.messageGenerators[i18n.getLocale()] || this.messageGenerators["en"];
148 | };
149 |
150 | private readonly handleStartCommand = async (
151 | ctx: Context<{message: tg.Update.New & tg.Update.NonChannel & tg.Message.TextMessage; update_id: number}>
152 | ): Promise => {
153 | const tgUser = ctx.update.message.from;
154 | const msg = this.getMessageGenerator(tgUser.language_code);
155 |
156 | const [userProfile] = await UserProfile.upsert({
157 | id: tgUser.id,
158 | fullName: joinFullName(tgUser.first_name, tgUser.last_name),
159 | username: tgUser.username,
160 | languageCode: tgUser.language_code,
161 | });
162 |
163 | await ctx.reply(msg.t("commands.start", {botName: ctx.botInfo.username}), {
164 | parse_mode: "Markdown",
165 | reply_markup: {
166 | inline_keyboard: [
167 | [
168 | {
169 | text: msg.t("button.play"),
170 | switch_inline_query_chosen_chat: {
171 | query: "",
172 | allow_bot_chats: false,
173 | allow_channel_chats: true,
174 | allow_group_chats: true,
175 | allow_user_chats: true,
176 | },
177 | },
178 | ],
179 | ],
180 | },
181 | });
182 | };
183 |
184 | private readonly handleStatsCommand = async (
185 | ctx: Context<{message: tg.Update.New & tg.Update.NonChannel & tg.Message.TextMessage; update_id: number}>
186 | ): Promise => {
187 | const tgUser = ctx.update.message.from;
188 | const msg = this.getMessageGenerator(tgUser.language_code);
189 |
190 | const [userProfile] = await UserProfile.upsert({
191 | id: tgUser.id,
192 | fullName: joinFullName(tgUser.first_name, tgUser.last_name),
193 | username: tgUser.username,
194 | languageCode: tgUser.language_code,
195 | });
196 |
197 | const userGamesPlayed = await PlayedGame.count({
198 | where: {
199 | [Op.or]: [{whitePlayerID: userProfile.id}, {blackPlayerID: userProfile.id}],
200 | },
201 | });
202 |
203 | const userGamesWon = await PlayedGame.count({
204 | where: {
205 | [Op.or]: [
206 | {
207 | [Op.and]: [{whitePlayerID: userProfile.id}, {winner: Color.White}],
208 | },
209 | {
210 | [Op.and]: [{blackPlayerID: userProfile.id}, {winner: Color.Black}],
211 | },
212 | ],
213 | },
214 | });
215 |
216 | const totalUniqueUsers = await UserProfile.count({});
217 |
218 | const todayUniqueUsers = await UserProfile.count({
219 | where: {
220 | updatedAt: {
221 | [Op.gte]: new Date(new Date().getTime() - 24 * 60 * 60 * 1000),
222 | },
223 | },
224 | });
225 |
226 | const totalGamesPlayed = await PlayedGame.count({});
227 |
228 | const todayGamesPlayed = await PlayedGame.count({
229 | where: {
230 | createdAt: {
231 | [Op.gte]: new Date(new Date().getTime() - 24 * 60 * 60 * 1000),
232 | },
233 | },
234 | });
235 |
236 | await ctx.reply(
237 | msg.t("commands.stats", {
238 | userGamesPlayed: userGamesPlayed.toString(),
239 | userGamesWon: userGamesWon.toString(),
240 | totalUniqueUsers: totalUniqueUsers.toString(),
241 | todayUniqueUsers: todayUniqueUsers.toString(),
242 | totalGamesPlayed: totalGamesPlayed.toString(),
243 | todayGamesPlayed: todayGamesPlayed.toString(),
244 | }),
245 | {
246 | parse_mode: "Markdown",
247 | }
248 | );
249 | };
250 |
251 | private readonly handleInlineQuery = async (ctx: NarrowedContext) => {
252 | log(
253 | 'Received new inline query("%s") from user(%d) with language code "%s"',
254 | ctx.inlineQuery.query,
255 | ctx.inlineQuery.from.id,
256 | ctx.inlineQuery.from.language_code
257 | );
258 | const msg = this.getMessageGenerator(ctx.inlineQuery.from.language_code);
259 | let games: InlineGameDescription[];
260 |
261 | const query = ctx.update.inline_query.query;
262 | if (query) {
263 | try {
264 | games = [
265 | {
266 | id: query,
267 | title: msg.t("rules.custom"),
268 | gameRules: parseGameRulesQuery(query),
269 | thumbnailUrl: this.customThumbnailURL,
270 | },
271 | ];
272 | } catch (e) {
273 | return;
274 | }
275 | } else {
276 | games = Object.values(this.predefinedGames);
277 | }
278 |
279 | const waitButton: tg.InlineKeyboardButton = {
280 | text: msg.t("button.wait"),
281 | callback_data: "creating-room",
282 | };
283 |
284 | const results: tg.InlineQueryResult[] = games.map((game) => ({
285 | type: "article",
286 | id: game.id,
287 | title: game.title,
288 | description: msg.gameRulesToString(game.gameRules, true),
289 | thumbnail_url: game.thumbnailUrl,
290 | input_message_content: {
291 | message_text: msg.creatingRoomMessage(),
292 | parse_mode: "Markdown",
293 | },
294 | reply_markup: {
295 | inline_keyboard: [[waitButton]],
296 | },
297 | }));
298 |
299 | await ctx.answerInlineQuery(results, {
300 | button: {
301 | web_app: {
302 | url: this.webAppCustomizeURL + "?lang=" + ctx.inlineQuery.from.language_code,
303 | },
304 | text: msg.t("rules.custom"),
305 | },
306 | cache_time: 0,
307 | });
308 | };
309 |
310 | private readonly handleInlineResult = async (
311 | ctx: NarrowedContext
312 | ): Promise => {
313 | const tgUser = ctx.update.chosen_inline_result.from;
314 | const msg = this.getMessageGenerator(tgUser.language_code);
315 |
316 | const host: User = {
317 | id: tgUser.id,
318 | fullName: joinFullName(tgUser.first_name, tgUser.last_name),
319 | username: tgUser.username,
320 | avatarURL: await this.getAvatar(tgUser.id),
321 | };
322 |
323 | log('User(%d: %s) selected inline result("%s")', host.id, host.fullName, ctx.chosenInlineResult.result_id);
324 |
325 | const resultID = ctx.chosenInlineResult.result_id;
326 | let gameRules: GameRules;
327 | if (resultID.startsWith("#")) {
328 | gameRules = this.predefinedGames[resultID.slice(1)].gameRules;
329 | } else {
330 | gameRules = parseGameRulesQuery(resultID);
331 | }
332 |
333 | const messageID = ctx.chosenInlineResult.inline_message_id!;
334 |
335 | await UserProfile.upsert({
336 | id: host.id,
337 | fullName: host.fullName,
338 | username: host.username,
339 | languageCode: tgUser.language_code,
340 | });
341 |
342 | const room = this.gameServer.createRoom(host, gameRules);
343 |
344 | const gameUrl = `https://t.me/${this.bot.botInfo!.username}/${this.webAppGameName}?startapp=${room.id}`;
345 |
346 | const updateMessage = async () => {
347 | let messageText: string;
348 | let keyboard: InlineKeyboardButton[][];
349 | if (room.gameStatus === GameStatus.NotStarted) {
350 | messageText = msg.notStartedMessage(room);
351 | const joinButton: tg.InlineKeyboardButton = {
352 | text: msg.t("button.join"),
353 | url: gameUrl,
354 | };
355 | keyboard = [[joinButton]];
356 | } else if (room.gameStatus === GameStatus.InProgress) {
357 | messageText = msg.inGameMessage(room);
358 | const joinButton: tg.InlineKeyboardButton = {
359 | text: msg.t("button.join"),
360 | url: gameUrl,
361 | };
362 | keyboard = [[joinButton]];
363 | } else if (room.gameStatus === GameStatus.Finished) {
364 | messageText = msg.gameFinishedMessage(room, ctx.botInfo.username);
365 | const playAgainButton: tg.InlineKeyboardButton = {
366 | text: msg.t("button.play-again"),
367 | switch_inline_query_current_chat: "",
368 | };
369 | const playAnotherButton: tg.InlineKeyboardButton = {
370 | text: msg.t("button.play-another"),
371 | switch_inline_query_chosen_chat: {
372 | query: "",
373 | allow_bot_chats: false,
374 | allow_channel_chats: true,
375 | allow_user_chats: true,
376 | allow_group_chats: true,
377 | },
378 | };
379 | keyboard = [[playAgainButton], [playAnotherButton]];
380 | } else {
381 | throw new Error("Invalid game status");
382 | }
383 |
384 | await this.bot.telegram.editMessageText(undefined, undefined, messageID, messageText, {
385 | parse_mode: "Markdown",
386 | reply_markup: {
387 | inline_keyboard: keyboard,
388 | },
389 | });
390 | };
391 |
392 | room.on("gameStatusChange", catchErrors()(updateMessage));
393 |
394 | room.on(
395 | "destroy",
396 | catchErrors()(async () => {
397 | if (room.gameStatus !== GameStatus.NotStarted) {
398 | return;
399 | }
400 |
401 | const playAgainButton: tg.InlineKeyboardButton = {
402 | text: msg.t("button.play-again"),
403 | switch_inline_query_current_chat: "",
404 | };
405 | const playAnotherButton: tg.InlineKeyboardButton = {
406 | text: msg.t("button.play-another"),
407 | switch_inline_query_chosen_chat: {
408 | query: "",
409 | allow_bot_chats: false,
410 | allow_channel_chats: true,
411 | allow_user_chats: true,
412 | allow_group_chats: true,
413 | },
414 | };
415 |
416 | const messageText = msg.roomDestroyedMessage(ctx.botInfo.username);
417 |
418 | await this.bot.telegram.editMessageText(undefined, undefined, messageID, messageText, {
419 | parse_mode: "Markdown",
420 | reply_markup: {
421 | inline_keyboard: [[playAgainButton], [playAnotherButton]],
422 | },
423 | });
424 | })
425 | );
426 |
427 | await updateMessage();
428 | };
429 |
430 | private readonly getFileLink = async (fileId: string | tg.File): Promise => {
431 | if (typeof fileId === "string") {
432 | fileId = await this.bot.telegram.getFile(fileId);
433 | } else if (fileId.file_path === undefined) {
434 | fileId = await this.bot.telegram.getFile(fileId.file_id);
435 | }
436 |
437 | // Local bot API instances return the absolute path to the file
438 | if (fileId.file_path !== undefined && isAbsolute(fileId.file_path)) {
439 | const url = new URL(this.bot.telegram.options.apiRoot);
440 | url.port = "";
441 | url.pathname = fileId.file_path;
442 | url.protocol = "file:";
443 | return url;
444 | }
445 |
446 | let path: string;
447 | if (this.bot.telegram.options.testEnv) {
448 | path = `./file/${this.bot.telegram.options.apiMode}${this.token}/test/${fileId.file_path!}`;
449 | } else {
450 | path = `./file/${this.bot.telegram.options.apiMode}${this.token}/${fileId.file_path!}`;
451 | }
452 |
453 | return new URL(path, this.bot.telegram.options.apiRoot);
454 | };
455 | }
456 |
--------------------------------------------------------------------------------
/server/src/Telegram/MessageGenerator.ts:
--------------------------------------------------------------------------------
1 | import i18n from "i18n";
2 |
3 | import {Color, GameRules, User} from "@/GameServer/DataModel";
4 | import {ServerRoom} from "@/GameServer/ServerRoom";
5 |
6 | export class MessageGenerator {
7 | public readonly locale: string;
8 |
9 | public constructor(locale: string) {
10 | this.locale = locale;
11 | }
12 |
13 | public readonly t = (phrase: string, replacements?: i18n.Replacements): string => {
14 | if (replacements) {
15 | return i18n.__mf({phrase, locale: this.locale}, replacements);
16 | }
17 |
18 | return i18n.__({phrase, locale: this.locale});
19 | };
20 |
21 | public readonly gameRulesToString = (gameRules: GameRules, firstPerson: boolean): string => {
22 | let description: string;
23 |
24 | if (gameRules.timer) {
25 | description = this.t("rules.timer.present", {
26 | start: (gameRules.initialTime! / 60).toString(),
27 | increment: gameRules.timerIncrement!.toString(),
28 | });
29 | } else {
30 | description = this.t("rules.timer.absent");
31 | }
32 |
33 | let colorPhrase = firstPerson ? "rules.color-1st-person" : "rules.color-3rd-person";
34 |
35 | if (gameRules.hostPreferredColor === Color.White) {
36 | colorPhrase += ".white";
37 | } else if (gameRules.hostPreferredColor === Color.Black) {
38 | colorPhrase += ".black";
39 | } else {
40 | colorPhrase += ".random";
41 | }
42 | description += " " + this.t(colorPhrase);
43 |
44 | return description;
45 | };
46 |
47 | public readonly creatingRoomMessage = (): string => {
48 | return this.t("message.creating-room");
49 | };
50 |
51 | public readonly roomDestroyedMessage = (botName: string): string => {
52 | return this.t("message.destroyed", {botName});
53 | };
54 |
55 | public readonly notStartedMessage = (room: ServerRoom): string => {
56 | const invitation = this.t("message.not-started.invitation", {
57 | user: this.inlineUserLink(room.host),
58 | });
59 |
60 | const rules = this.t("message.not-started.rules", {
61 | rules: this.gameRulesToString(room.gameRules, false),
62 | });
63 |
64 | const guide = this.t("message.not-started.guide");
65 |
66 | return `${invitation}\n\n${rules}\n\n${guide}`;
67 | };
68 |
69 | public readonly inGameMessage = (room: ServerRoom): string => {
70 | const introduction = this.t("message.in-game.introduction");
71 |
72 | const description = this.t("message.in-game.description", {
73 | whitePlayer: this.inlineUserLink(room.whitePlayer),
74 | blackPlayer: this.inlineUserLink(room.blackPlayer),
75 | });
76 |
77 | const rules = this.t("message.in-game.rules", {
78 | rules: this.gameRulesToString(room.gameRules, false),
79 | });
80 |
81 | const guide = this.t("message.in-game.guide");
82 |
83 | return `${introduction}\n${description}\n\n${rules}\n\n${guide}`;
84 | };
85 |
86 | public readonly gameFinishedMessage = (room: ServerRoom, botName: string): string => {
87 | const gameState = room.gameState();
88 |
89 | let resolution;
90 | let replacements: i18n.Replacements | undefined;
91 | if (gameState.winnerID) {
92 | if (gameState.winnerID === room.whitePlayer.id) {
93 | replacements = {
94 | winner: this.inlineUserLink(room.whitePlayer),
95 | loser: this.inlineUserLink(room.blackPlayer),
96 | };
97 |
98 | resolution = this.t("message.finished.resolution.victory.white");
99 | } else {
100 | replacements = {
101 | winner: this.inlineUserLink(room.blackPlayer),
102 | loser: this.inlineUserLink(room.whitePlayer),
103 | };
104 | resolution = this.t("message.finished.resolution.victory.black");
105 | }
106 | } else {
107 | resolution = this.t("message.finished.resolution.draw");
108 | }
109 |
110 | const explanation = this.t("message.finished.explanation." + gameState.resolution, replacements);
111 |
112 | const description = this.t("message.finished.description", {
113 | whitePlayer: this.inlineUserLink(room.whitePlayer),
114 | blackPlayer: this.inlineUserLink(room.blackPlayer),
115 | });
116 |
117 | const guide = this.t("message.finished.guide", {botName});
118 |
119 | return `${resolution}\n${explanation}\n\n${description}\n\n${guide}`;
120 | };
121 |
122 | private readonly inlineUserLink = (user: User): string => {
123 | return `[${user.fullName + (user.username ? ` (@${user.username})` : "")}](tg://user?id=${user.id})`;
124 | };
125 | }
126 |
--------------------------------------------------------------------------------
/server/src/Telegram/Types.ts:
--------------------------------------------------------------------------------
1 | export interface WebAppUser {
2 | id: number;
3 | is_bot?: boolean;
4 | first_name: string;
5 | last_name?: string;
6 | username?: string;
7 | language_code?: string;
8 | is_premium?: true;
9 | added_to_attachment_menu?: true;
10 | allows_write_to_pm?: true;
11 | photo_url?: string;
12 | }
13 |
14 | export interface WebAppChat {
15 | id: number;
16 | type: string;
17 | title: string;
18 | username?: string;
19 | photo_url?: string;
20 | }
21 |
22 | export interface WebAppInitData {
23 | query_id?: string;
24 | user?: WebAppUser;
25 | receiver?: WebAppUser;
26 | chat?: WebAppChat;
27 | chat_type?: string;
28 | chat_instance?: string;
29 | start_param?: string;
30 | can_send_after?: number;
31 | auth_date: number;
32 | hash: string;
33 | }
34 |
--------------------------------------------------------------------------------
/server/src/Telegram/joinFullname.ts:
--------------------------------------------------------------------------------
1 | export const joinFullName = (firstName: string, lastName?: string): string => {
2 | if (!lastName) {
3 | return firstName;
4 | }
5 | return `${firstName} ${lastName}`;
6 | };
7 |
--------------------------------------------------------------------------------
/server/src/Telegram/parseGameRulesQuery.ts:
--------------------------------------------------------------------------------
1 | import {Color, GameRules} from "@/GameServer/DataModel";
2 |
3 | export const parseGameRulesQuery = (query: string): GameRules => {
4 | const regex = /^\$(0|1)(?::(\d+):(\d+))?:(w|b|r)$/;
5 |
6 | const match = query.trim().match(regex);
7 |
8 | if (!match) {
9 | throw new Error("Invalid query");
10 | }
11 |
12 | const [, timerEnabledStr, initialTimeStr, timerIncrementStr, hostPreferredColorStr] = match;
13 |
14 | const timerEnabled: boolean = timerEnabledStr === "1";
15 | const hostPreferredColor: Color | undefined =
16 | hostPreferredColorStr === "r" ? undefined : (hostPreferredColorStr as Color);
17 |
18 | let initialTime: number | undefined;
19 | let timerIncrement: number | undefined;
20 |
21 | if (timerEnabled) {
22 | if (!initialTimeStr || !timerIncrementStr) {
23 | throw new Error("Invalid query");
24 | }
25 |
26 | initialTime = parseInt(initialTimeStr, 10);
27 | timerIncrement = parseInt(timerIncrementStr, 10);
28 | }
29 |
30 | return {
31 | hostPreferredColor,
32 | timer: timerEnabled,
33 | initialTime,
34 | timerIncrement,
35 | };
36 | };
37 |
--------------------------------------------------------------------------------
/server/src/Telegram/parseInitData.ts:
--------------------------------------------------------------------------------
1 | import config from "config";
2 | import crypto, {BinaryLike, BinaryToTextEncoding, KeyObject} from "crypto";
3 |
4 | import {WebAppInitData} from "@/Telegram/Types";
5 |
6 | function HmacSHA256(key: BinaryLike | KeyObject, data: string): Buffer;
7 | function HmacSHA256(key: BinaryLike | KeyObject, data: string, encoding: BinaryToTextEncoding): string;
8 |
9 | function HmacSHA256(key: BinaryLike | KeyObject, data: string, encoding?: BinaryToTextEncoding) {
10 | const hmac = crypto.createHmac("sha256", key);
11 | hmac.update(data);
12 | if (encoding) {
13 | return hmac.digest(encoding);
14 | } else {
15 | return hmac.digest();
16 | }
17 | }
18 |
19 | const secretKey = HmacSHA256("WebAppData", config.get("bot.token"));
20 |
21 | class ValidationError extends Error {
22 | constructor(message?: string) {
23 | super(message);
24 | this.name = "ValidationError";
25 | }
26 | }
27 |
28 | export function parseInitData(initData: string, validate: boolean = true): WebAppInitData {
29 | const params = new URLSearchParams(initData);
30 |
31 | if (validate) {
32 | const providedHash = params.get("hash");
33 | if (!providedHash) {
34 | throw new ValidationError('"hash" parameter is not present in initData');
35 | }
36 | params.delete("hash");
37 |
38 | const entries = Array.from(params.entries());
39 | entries.sort((a, b) => a[0].localeCompare(b[0]));
40 |
41 | const pairs = entries.map(([key, value]) => key + "=" + value);
42 | const dataCheckString = pairs.join("\n");
43 |
44 | const calculatedHash = HmacSHA256(secretKey, dataCheckString, "hex");
45 |
46 | if (providedHash !== calculatedHash) {
47 | throw new ValidationError("Hash mismatch");
48 | }
49 |
50 | params.set("hash", providedHash);
51 | }
52 |
53 | const entries = Array.from(params.entries());
54 | const initDataObject: {[key: string]: any} = {};
55 |
56 | for (const [key, value] of entries) {
57 | if (key === "can_send_after" || key === "auth_date") {
58 | initDataObject[key] = Number.parseInt(value);
59 | } else if (
60 | (value.slice(0, 1) == "{" && value.slice(-1) == "}") ||
61 | (value.slice(0, 1) == "[" && value.slice(-1) == "]")
62 | ) {
63 | initDataObject[key] = JSON.parse(value);
64 | } else {
65 | initDataObject[key] = value;
66 | }
67 | }
68 |
69 | return initDataObject as WebAppInitData;
70 | }
71 |
--------------------------------------------------------------------------------
/server/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from "i18n";
2 | import path from "path";
3 |
4 | i18n.configure({
5 | directory: path.join("locales"),
6 | defaultLocale: "en",
7 | objectNotation: true,
8 | retryInDefaultLocale: true,
9 | updateFiles: false,
10 | });
11 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import "@/i18n";
3 |
4 | import config from "config";
5 | import * as fs from "fs";
6 | import http from "http";
7 | import https from "https";
8 | import serveStatic from "serve-static";
9 |
10 | import {GameServer} from "@/GameServer/GameServer";
11 | import {ChessNowBot} from "@/Telegram/ChessNowBot";
12 |
13 | const serve = serveStatic("public", {
14 | index: ["index.html"],
15 | });
16 |
17 | let requestHandler;
18 | if (config.get("server.static")) {
19 | requestHandler = (
20 | req: http.IncomingMessage,
21 | res: http.ServerResponse & {req: http.IncomingMessage}
22 | ) => {
23 | serve(req, res, (err) => {
24 | res.writeHead(err?.statusCode || 404);
25 | res.end();
26 | });
27 | };
28 | }
29 |
30 | let server: http.Server | https.Server;
31 |
32 | if (config.get("server.https")) {
33 | const options = {
34 | key: fs.readFileSync(config.get("server.key")),
35 | cert: fs.readFileSync(config.get("server.cert")),
36 | };
37 |
38 | server = https.createServer(options, requestHandler);
39 | } else {
40 | server = http.createServer(requestHandler);
41 | }
42 |
43 | const gameServer = new GameServer(server);
44 |
45 | export const bot = new ChessNowBot(gameServer);
46 |
47 | server.listen(config.get("server.port"));
48 | bot.launch();
49 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es2016",
15 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
17 | // "jsx": "preserve", /* Specify what JSX code is generated. */
18 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
27 |
28 | /* Modules */
29 | "module": "commonjs",
30 | /* Specify what module code is generated. */
31 | "rootDir": "./src", /* Specify the root folder within your source files. */
32 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
33 | // "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */
34 | "paths": {
35 | "@/*": [
36 | "./src/*",
37 | "./dist/*"
38 | ]
39 | },
40 | /* Specify a set of entries that re-map imports to additional lookup locations. */
41 | // "rootDirs": ["./src"], /* Allow multiple folders to be treated as one when resolving modules. */
42 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
43 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
44 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
45 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
46 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
47 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
48 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
49 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
50 | // "resolveJsonModule": true, /* Enable importing .json files. */
51 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
52 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
53 |
54 | /* JavaScript Support */
55 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
56 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
57 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
58 |
59 | /* Emit */
60 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
61 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
62 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
63 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
64 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
65 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
66 | "outDir": "./dist", /* Specify an output folder for all emitted files. */
67 | // "removeComments": true, /* Disable emitting comments. */
68 | // "noEmit": true, /* Disable emitting files from a compilation. */
69 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
70 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
71 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
72 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
73 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
74 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
75 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
76 | // "newLine": "crlf", /* Set the newline character for emitting files. */
77 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
78 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
79 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
80 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
81 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
82 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
83 |
84 | /* Interop Constraints */
85 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
86 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
87 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
88 | "esModuleInterop": true,
89 | /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
90 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
91 | "forceConsistentCasingInFileNames": true,
92 | /* Ensure that casing is correct in imports. */
93 |
94 | /* Type Checking */
95 | "strict": true,
96 | /* Enable all strict type-checking options. */
97 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
98 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
99 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
100 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
101 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
102 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
103 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
104 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
105 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
106 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
107 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
108 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
109 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
110 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
111 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
112 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
113 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
114 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
115 |
116 | /* Completeness */
117 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
118 | "skipLibCheck": true
119 | /* Skip type checking all .d.ts files. */
120 | },
121 | }
122 |
--------------------------------------------------------------------------------